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.
What you need
Section titled “What you need”- 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.
Deploy for the first time
Section titled “Deploy for the first time”-
Create your production settings file:
Terminal window bun run deploy:init awsIt writes
.env.awswith a newAUTH_SECRET. The file is never committed. -
Fill in
.env.aws. The minimum:Terminal window AWS_PROJECT=my-saasAWS_REGION=us-east-1WEB_DOMAIN=app.example.comSITE_DOMAIN=example.comAWS_HOSTED_ZONE=example.comADMIN_EMAILS=you@example.comSMTP_HOST=smtp.mailgun.orgSMTP_PORT=587SMTP_USER=...SMTP_PASSWORD=...MAIL_FROM=My SaaS <hello@example.com> -
Preview, then apply.
deploy:plan:awschanges nothing.deploy:apply:awsshows every change and asks before making it:Terminal window bun run deploy:plan:awsbun run deploy:apply:awsThe first one takes 20 to 30 minutes, most of it creating the database. It also prepares the account for CDK, once per region.
-
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
What .env.aws controls
Section titled “What .env.aws controls”| 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.
If your DNS is not in Route 53
Section titled “If your DNS is not in Route 53”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.
What happens on each release
Section titled “What happens on each release”Run bun run deploy:apply:aws again. Nothing deploys on a push; put it in your CI if you want
that. Then:
- Images that changed are built and pushed.
- 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.
- 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/healthto its own container check. See Health checks. - 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.
Watch it
Section titled “Watch it”Logs are in CloudWatch, in the group the deploy prints as LogGroup, one stream per task:
api/…, web/…, site/…. Follow the API with:
aws logs tail <LogGroup> --follow --log-stream-name-prefix apiTake it down
Section titled “Take it down”bun run deploy:destroy:awsIt 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.