Skip to content

AWS

Your whole production on AWS is described in packages/deploy/src/aws/stack.ts, with the AWS CDK. One command builds the images, pushes them and creates or changes everything:

Part On AWS
api, web, site ECS on Fargate, on ARM. A worker too with AWS_WORKER=true
Postgres RDS for PostgreSQL 18, encrypted, backed up for 7 days
Redis ElastiCache for Valkey, set never to evict, so no job is lost
HTTPS An Application Load Balancer, with a certificate from ACM for both domains
Your settings Secrets Manager, read by the services when they start
Logs CloudWatch, kept for a month

Railway stays the default and the simplest. Choose AWS when you need to be in your own AWS account. A small product costs about $50 to $60 a month there: the load balancer, the database, Redis and three small tasks.

  • An AWS account, and the AWS CLI signed in to it (aws login, or any profile it can use). The commands use the same credentials.
  • Your own domains. A load balancer has no HTTPS address of its own. Best is a hosted zone in Route 53 that holds them: the certificate and the DNS records are then made for you.
  • Docker running: the images are built on your machine.
  • An email provider. Without one nobody can sign in. See Email provider.
  1. Create your production settings file:

    Terminal window
    bun run deploy:init aws

    It writes .env.aws with a new AUTH_SECRET. The file is never committed.

  2. Fill in .env.aws. The minimum:

    Terminal window
    AWS_PROJECT=my-saas
    AWS_REGION=us-east-1
    WEB_DOMAIN=app.example.com
    SITE_DOMAIN=example.com
    AWS_HOSTED_ZONE=example.com
    ADMIN_EMAILS=you@example.com
    SMTP_HOST=smtp.mailgun.org
    SMTP_PORT=587
    SMTP_USER=...
    SMTP_PASSWORD=...
    MAIL_FROM=My SaaS <hello@example.com>
  3. Preview, then apply. deploy:plan:aws changes nothing. deploy:apply:aws shows every change and asks before making it:

    Terminal window
    bun run deploy:plan:aws
    bun run deploy:apply:aws

    The first one takes 20 to 30 minutes, most of it creating the database. It also prepares the account for CDK, once per region.

  4. Check it the way a visitor reaches it:

    Terminal window
    bun run deploy:check https://app.example.com --site https://example.com --email you@example.com
Variable Effect
AWS_PROJECT The stack’s name, and what everything is named after
AWS_REGION Where it runs
WEB_DOMAIN, SITE_DOMAIN Your domains, without https://
AWS_HOSTED_ZONE The Route 53 zone they are in. See below if your DNS is elsewhere
AWS_CERTIFICATE_ARN Instead of a zone: a certificate in ACM, in the same region, for both domains
AWS_WORKER true runs jobs in a separate worker service
AWS_NAT true puts the services in private subnets, behind a NAT gateway (~$32/month)
AWS_DATABASE_INSTANCE Default t4g.micro
AWS_REDIS_NODE Default cache.t4g.micro
AWS_DELETION_PROTECTION Default true. false lets deploy:destroy:aws delete the database
Everything else Passed to the API. See Environment variables

What the API is told goes through Secrets Manager, as <AWS_PROJECT>/settings: apply writes what you filled in there, and the services read it when they start. It is never part of the template CloudFormation sees. A value you leave empty is left as it is in Secrets Manager, so a secret can live only there. To remove one, delete its key in the console.

The database’s password is made by AWS and never leaves Secrets Manager. The API is given it in parts (PGHOST, PGUSER, PGPASSWORD…) and puts its DATABASE_URL together itself.

Leave AWS_HOSTED_ZONE empty. In ACM, in the same region, request a certificate for both domains, validate it with the records ACM shows you, and put its ARN in AWS_CERTIFICATE_ARN. After the first deploy, point both domains at the LoadBalancerAddress it prints, with a CNAME.

Run bun run deploy:apply:aws again. Nothing deploys on a push; put it in your CI if you want that. Then:

  1. Images that changed are built and pushed.
  2. The migrations run in a task of their own, with the new image. If they fail, nothing else changes and the version that is running keeps serving. The error says where their output is.
  3. Each service starts its new version beside the old one. The load balancer asks the web app and the site for /healthz; the API answers /api/health to its own container check. See Health checks.
  4. Only once the new version is healthy is the old one stopped. If it never gets healthy, ECS goes back to the old one by itself.

So a migration must work with the code that is still running: add a column in one release, stop using the old one in the next.

Logs are in CloudWatch, in the group the deploy prints as LogGroup, one stream per task: api/…, web/…, site/…. Follow the API with:

Terminal window
aws logs tail <LogGroup> --follow --log-stream-name-prefix api
Terminal window
bun run deploy:destroy:aws

It asks first. With AWS_DELETION_PROTECTION=true, the default, the database can’t be deleted: set it to false and apply first, and know that the data goes with it. Your settings stay in Secrets Manager until you delete them there.

Errors: see Troubleshooting.