Skip to content

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:

scp -i ~/.ssh/lightsail-us-east-2.pem github-app.pem ubuntu@<ip>:factoryal/secrets/github-app.pem

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 -p with 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.
  • agent steps 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 login doesn'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_usd on code steps, use the CLI's estimate at API prices. On a subscription that's not what you pay.

4. Deploy

deploy/lightsail/deploy.sh

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:

FACTORYAL_FACTORIES={"demo": {"repo": "/factoryal-src", "branch": "main", "path": "examples/demo"}}

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.