Workshops ... Part 11: Deploy to AWS with infrastructure as code

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_URL the same way it does locally, so the only change on AWS is the Aurora endpoint (Database.Endpoint.Address) and the generated password.
  • The ImageId resolves 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 AmazonSSMManagedInstanceCore and grants secretsmanager: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.

Questions & Answers

Sign up to ask questions, track your progress, and get access to other workshops · Already have an account? Sign in