Part 11: Deploy to AWS with infrastructure as code
Locally we run the app and Postgres together with Docker Compose. On AWS we make a different choice. We don't run Compose on a server and babysit our own database there. Instead we let AWS run the database for us with Aurora, and we run the app container on a small EC2 instance pointed at it.
We describe the whole setup as infrastructure as code. We use CloudFormation here, but Terraform, Pulumi, or the AWS CDK express the same idea. Either way we keep it in the repo rather than as console clicks nobody can reproduce. You can skip the deployment entirely and still have a working local app, or adapt the idea to any other cloud.
The resources we provision
The template creates these resources:
- a managed Postgres database with Aurora Serverless v2
- two Secrets Manager secrets for the database password and the app's signing key
- an EC2 instance that runs the app container
- security groups that open the web port and let the instance reach the database
- an IAM role that lets the instance read the secrets and receive updates without SSH
When the instance boots, it reads the secrets, builds the app image, and runs it
with DATABASE_URL pointed at the Aurora endpoint. There's no Compose on the
server. The database lives in Aurora, so the instance runs only one container.
Ask the assistant to write the template:
Write a CloudFormation template (cloudformation.yaml) that deploys this app to
AWS. Provision an Aurora Serverless v2 PostgreSQL database, and generate its
password and the app's secret key in Secrets Manager. Run the app on a single EC2
instance that reads those secrets at boot and runs the container with docker. Open
port 80, let the instance reach Postgres, and attach an IAM role with
AmazonSSMManagedInstanceCore plus permission to read the secrets. Make the deploy
idempotent: re-running it changes nothing when nothing changed and never rotates
the secrets. Output the public URL.
The template generates the two secrets and points Aurora at the database one:
Resources:
DBSecret:
Type: AWS::SecretsManager::Secret
Properties:
GenerateSecretString:
PasswordLength: 24
ExcludePunctuation: true
AppSecret:
Type: AWS::SecretsManager::Secret
Properties:
GenerateSecretString:
PasswordLength: 48
ExcludePunctuation: true
Database:
Type: AWS::RDS::DBCluster
Properties:
Engine: aurora-postgresql
DatabaseName: snakearena
MasterUsername: snakearena
MasterUserPassword: !Sub '{{resolve:secretsmanager:${DBSecret}}}'
ServerlessV2ScalingConfiguration:
MinCapacity: 0.5
MaxCapacity: 4
VpcSecurityGroupIds: [!Ref DBSecurityGroup]
DatabaseInstance:
Type: AWS::RDS::DBInstance
Properties:
DBClusterIdentifier: !Ref Database
Engine: aurora-postgresql
DBInstanceClass: db.serverless
The EC2 instance reads both secrets at boot, builds the image, and runs the container against the database:
WebServer:
Type: AWS::EC2::Instance
Properties:
InstanceType: t3.small
ImageId: '{{resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64}}'
IamInstanceProfile: !Ref WebInstanceProfile
SecurityGroupIds: [!Ref WebSecurityGroup]
UserData:
Fn::Base64: !Sub |
#!/bin/bash
dnf install -y docker git
systemctl enable --now docker
DBPASS=$(aws secretsmanager get-secret-value --secret-id ${DBSecret} --query SecretString --output text)
KEY=$(aws secretsmanager get-secret-value --secret-id ${AppSecret} --query SecretString --output text)
git clone ${RepoUrl} /opt/snake-arena
cd /opt/snake-arena
printf 'DATABASE_URL=postgresql://snakearena:%s@${Database.Endpoint.Address}:5432/snakearena\nSECRET_KEY=%s\nDEBUG=false\n' "$DBPASS" "$KEY" > app.env
docker build -f backend/Dockerfile -t snake-arena .
docker run -d --restart always -p 80:8000 --env-file app.env --name snake-arena snake-arena
Outputs:
AppUrl:
Value: !Sub 'http://${WebServer.PublicDnsName}'
A few choices in this template matter:
- The passwords never appear in the repo or in the deploy command. Secrets
Manager generates them, Aurora reads the database one, and the instance reads
both at boot to write its
app.env. - The app reads
DATABASE_URLthe same way it does locally, so the only change on AWS is the Aurora endpoint (Database.Endpoint.Address) and the generated password. - The
ImageIdresolves the latest Amazon Linux 2023 image at deploy time through SSM, so we never hardcode an AMI that goes stale. - The IAM instance profile attaches
AmazonSSMManagedInstanceCoreand grantssecretsmanager:GetSecretValue, so the boot script can read the secrets and the pipeline in Part 12: CI/CD with GitHub Actions can ship updates without SSH keys.
You can also split this into two stacks if that's easier to manage. Put Aurora and the secrets in a data stack, and the instance that reads them in an app stack. Then redeploying or tearing down the app leaves the database untouched.
Running costs
You pay nothing for the local parts of this workshop. Lovable, your coding assistant, and Docker all run without a bill. You only spend money on the AWS part.
Here's roughly what each piece costs to leave running (approximate, us-east-1, mid-2026):
- EC2
t3.small: about $0.02 an hour, so roughly $15 a month if you leave it on. - Aurora Serverless v2 (PostgreSQL): about $0.06 an hour at its 0.5-ACU floor, so roughly $40 a month, plus about $0.10 per GB-month of storage.
- Secrets Manager: about $0.40 per secret a month, so under a dollar for the two.
- Data transfer and the other small charges add a few dollars.
A stack left running costs on the order of a couple of dollars a day. Nothing runs after you delete the stack, so tear it down when you're done and the charges stop.
Deploy it
Sign in with the AWS CLI first (aws configure).
Then deploy the stack with this command:
aws cloudformation deploy \
--template-file cloudformation.yaml \
--stack-name snake-arena \
--capabilities CAPABILITY_IAM \
--parameter-overrides RepoUrl=https://github.com/<you>/<repo>.git
--capabilities CAPABILITY_IAM is required because the stack creates the IAM
role. The first deploy takes several minutes while Aurora provisions and the
instance boots and builds the image.
Deploying again is safe any time, because the deploy is idempotent. CloudFormation compares the template to what's already running and changes only the difference. The passwords live in Secrets Manager, so re-running never rotates them or logs anyone out.
Read the public URL from the stack outputs:
aws cloudformation describe-stacks --stack-name snake-arena \
--query 'Stacks[0].Outputs'
Open that URL and the app is live on AWS, backed by Aurora.
When you're done experimenting, tear it all down so nothing keeps costing money:
aws cloudformation delete-stack --stack-name snake-arena
New versions don't need another stack deploy. The pipeline in Part 12: CI/CD with GitHub Actions runs the tests on every push. When they pass, it rebuilds the container on the instance, leaving Aurora and the rest of the stack untouched.