Deploying to AWS Lightsail¶
This runs the whole stack (API and web UI, dispatcher, workers, Postgres and a Temporal dev server) with Docker Compose on one Lightsail instance, behind Caddy for HTTPS. It costs little and has few moving parts. It's one box, though: no autoscaling, and a restart or deploy means a short outage. For production, use the AWS (CDK) deployment.
| Piece | Where |
|---|---|
| HTTPS | Caddy, with a free Let's Encrypt certificate for FACTORYAL_HOSTNAME |
| Public ports | 22 (SSH), 80 and 443 (Caddy). Postgres, Temporal and the API stay inside the compose network |
| Temporal UI | Only over an SSH tunnel |
| Files | deploy/lightsail/: compose overrides, Caddyfile and scripts |
No domain? setup.sh sets FACTORYAL_HOSTNAME to <ip-with-dashes>.sslip.io.
sslip.io resolves that name to the instance's IP, so Caddy can still get
a real certificate. You can switch to your own domain later
(see Operating it).
1. Create the instance¶
From a machine with the AWS CLI (aws login), in us-east-2. Each command is one line.
Create an Ubuntu 24.04 instance on the 4 GB plan (the stack runs about 8 processes):
aws lightsail create-instances --region us-east-2 --instance-names factoryal --availability-zone us-east-2a --blueprint-id ubuntu_24_04 --bundle-id "$(aws lightsail get-bundles --region us-east-2 --query "bundles[?ramSizeInGb==\`4.0\` && isActive && contains(supportedPlatforms, 'LINUX_UNIX')].bundleId | [0]" --output text)"
Give it a static IP, so the hostname doesn't change when the instance restarts:
aws lightsail allocate-static-ip --region us-east-2 --static-ip-name factoryal-ip
aws lightsail attach-static-ip --region us-east-2 --static-ip-name factoryal-ip --instance-name factoryal
aws lightsail get-static-ip --region us-east-2 --static-ip-name factoryal-ip --query staticIp.ipAddress --output text
Open only SSH and HTTP(S):
aws lightsail put-instance-public-ports --region us-east-2 --instance-name factoryal --port-infos fromPort=22,toPort=22,protocol=tcp fromPort=80,toPort=80,protocol=tcp fromPort=443,toPort=443,protocol=tcp
Turn on daily snapshots (they back up Postgres and Temporal too):
aws lightsail enable-add-on --region us-east-2 --resource-name factoryal --add-on-request addOnType=AutoSnapshot
Get the SSH key and log in (<ip> is the static IP from above):
aws lightsail download-default-key-pair --region us-east-2 --query privateKeyBase64 --output text > ~/.ssh/lightsail-us-east-2.pem && chmod 600 ~/.ssh/lightsail-us-east-2.pem
ssh -i ~/.ssh/lightsail-us-east-2.pem ubuntu@<ip>
2. Set it up¶
On the instance. The repository is private, so give the instance a read-only deploy key. Create the key:
ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519 -C factoryal-lightsail && cat ~/.ssh/id_ed25519.pub
On GitHub, go to the repo → Settings → Deploy keys → Add deploy key, and paste the key. Leave write access off. Then clone the repo and run the setup:
git clone git@github.com:juankinder/forge.git factoryal && cd factoryal && sudo deploy/lightsail/setup.sh
setup.sh installs Docker and adds swap. It also creates .env with a generated admin token
(printed once; it's FACTORYAL_API_TOKEN in .env), a worker token, a token secret, a run-token
secret, the sslip.io hostname, and an install id other than dev
(FACTORYAL_INSTALL_ID=lightsail-<ip-with-dashes>). With that install id, the API refuses to
start with a public or empty admin token, a public or missing worker token or run-token secret, or
a public token secret, so a later edit of .env that keeps this line can't silently put a public
value back. A fresh copy of .env.example has it commented out, which means dev and no checks:
if you start .env again from it, set the same install id again. Keep the install id once the
install runs: it names the install in every event and is bound into encrypted payloads. If you
write .env yourself instead, replace every token and secret .env.example holds (those values
are public), and set FACTORYAL_INSTALL_ID the same way. Log out and back in so your user can run
Docker, then edit .env:
| Setting | Set it to |
|---|---|
ANTHROPIC_API_KEY |
your key. agent steps always need it; with section 3, llm steps and coding agents don't |
FACTORYAL_GITHUB_* |
your GitHub App (GitHub App). Its webhook URL is https://<FACTORYAL_HOSTNAME>/v1/integrations/github/webhook |
Copy the App's private key over from your machine:
3. Claude on a subscription¶
Optional. Factoryal can run Claude through the Claude Code CLI instead of the API, logged in with your Claude subscription:
| Setting | Runs on the CLI |
|---|---|
FACTORYAL_LLM_PROVIDER=claude_code |
llm steps (release notes, PR reviews, ...), agent steps, and the grader that checks each coding result |
FACTORYAL_CODING_BACKEND=claude_code |
code steps: coding agents run as Claude Code on the coding worker (Agents) |
Log the CLI in from the instance. This builds the worker image with the CLIs and runs
claude setup-token in it. The first build can take 15 to 20 minutes, because it installs
Node and npm from Debian's packages:
deploy/lightsail/compose.sh build worker-llm && deploy/lightsail/compose.sh run --rm --no-deps worker-llm claude setup-token
It prints a URL. Open it in your own browser, approve, and paste the code back into the SSH
session. It then prints a token that lasts a year. Add it and the two settings to .env:
CLAUDE_CODE_OAUTH_TOKEN=<the token>
FACTORYAL_LLM_PROVIDER=claude_code
FACTORYAL_CODING_BACKEND=claude_code
Run claude setup-token again before the token expires.
How it works:
- Each call runs
claude -pwith no tools, no settings, no MCP servers and a throwaway home directory. The prompt goes in on stdin. The CLI gets its own credentials (CLAUDE_CODE_OAUTH_TOKEN,ANTHROPIC_*) and none of Factoryal's secrets. agentsteps are the same, plus one MCP server with the agent's catalog tools. It holds only the agent's short-lived scoped token, never Factoryal's own API token.- With a token set, the CLI never gets
ANTHROPIC_API_KEY. Otherwise the CLI would use the key instead of the subscription. - A plain
claude logindoesn't work here: its credentials live in a home directory the workers don't use. Use the token.
Things to know:
- Terms. Subscription plans are for your own use of Claude Code. Check Anthropic's terms before you run a service on one that other people use, e.g. teammates' runs or reviews of other people's PRs. An API key (or Claude on Bedrock) is the supported way for a team.
- Limits. A subscription has rolling usage limits. Calls over the limit fail and are retried, so runs wait or fail until the limit resets.
- Cost. Run costs, and
budget_usdoncodesteps, use the CLI's estimate at API prices. On a subscription that's not what you pay.
4. Deploy¶
It pulls the latest code, rebuilds and restarts what changed, then shows the services.
Caddy gets the certificate on first start, so give it a minute. Then open
https://<FACTORYAL_HOSTNAME> and log in with the admin token.
Configuration (blueprints, agents, workflows) comes from factories: git repos the
dispatcher applies (Factories as code). Connect a factory's GitHub repo on
the web UI's Factories page. Or, to try the example factory from this checkout, add
this to .env (use the branch the instance is on) and run deploy/lightsail/compose.sh up -d:
Operating it¶
| Task | Command (on the instance, in ~/factoryal) |
|---|---|
| Deploy the latest commit of the checked-out branch | deploy/lightsail/deploy.sh |
| Status | deploy/lightsail/compose.sh ps |
| Logs | deploy/lightsail/compose.sh logs -f --tail 100 worker-llm |
| More LLM workers | deploy/lightsail/compose.sh up -d --scale worker-llm=3 |
Restart after editing .env |
deploy/lightsail/compose.sh up -d |
| Temporal UI | From your machine: ssh -i ~/.ssh/lightsail-us-east-2.pem -L 8233:localhost:8233 ubuntu@<ip>, then http://localhost:8233 |
Moving to your own domain. Point an A record at the static IP. Set
FACTORYAL_HOSTNAME=factoryal.example.com in .env, run deploy/lightsail/compose.sh up -d,
and change the GitHub App's webhook URL.
The landing page, the app and the docs on their own hostnames (e.g. example.com,
app.example.com, docs.example.com): copy deploy/lightsail/sites/domains.caddy.example to
deploy/lightsail/sites/domains.caddy on the instance, replace example.com, add an A record
for each hostname and run deploy/lightsail/compose.sh restart caddy. Keep FACTORYAL_HOSTNAME
on a name that isn't in domains.caddy, such as the sslip.io one: Caddy refuses to start when
two sites share a hostname. The file isn't in git, so
deploys keep it. GitHub and Jira webhooks then go to the app's hostname.
Temporal Cloud instead of the local dev server. The dev server keeps everything in one
SQLite file on the instance. Snapshots back it up, but it isn't meant for heavy use. To use
Temporal Cloud, set FACTORYAL_TEMPORAL_API_KEY and FACTORYAL_TEMPORAL_NAMESPACE in .env,
plus FACTORYAL_TEMPORAL_ADDRESS in each Factoryal service's environment in
deploy/lightsail/docker-compose.yml. The base compose file sets that one to the local
server, and a service's environment wins over .env.
Restoring. Create a new instance from a snapshot in the Lightsail console, then move the static IP to it.