Skip to content

AWS

Below is the complete bootstrap procedure for the AWS prerequisites in development infrastructure.

Assumptions:

  • Region: ap-south-1
  • Repository: dichit-money/dichit-backend
  • EC2 OS: Ubuntu 24.04
  • EC2 instance already exists
  • dichit.money is managed through Route 53
  • Commands are run from an administrator workstation unless marked “on EC2”

1. Configure local AWS access

export AWS_REGION=ap-south-1
export INSTANCE_ID=i-REPLACE_ME
export EC2_ROLE_NAME=dichit-development-ec2
export GITHUB_REPOSITORY=dichit-money/dichit-backend

aws sts get-caller-identity

The caller needs permission to manage CloudFormation, IAM, S3, Secrets Manager, EC2 tags, AWS Backup, SSM and Route 53.

2. Create or configure the EC2 instance role

If the instance already has an IAM role, reuse it and set EC2_ROLE_NAME to its role name. Otherwise, create one:

aws iam create-role \
  --role-name "$EC2_ROLE_NAME" \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {"Service": "ec2.amazonaws.com"},
      "Action": "sts:AssumeRole"
    }]
  }'

aws iam attach-role-policy \
  --role-name "$EC2_ROLE_NAME" \
  --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore

aws iam create-instance-profile \
  --instance-profile-name "$EC2_ROLE_NAME"

aws iam add-role-to-instance-profile \
  --instance-profile-name "$EC2_ROLE_NAME" \
  --role-name "$EC2_ROLE_NAME"

Attach it to EC2:

aws ec2 associate-iam-instance-profile \
  --region "$AWS_REGION" \
  --instance-id "$INSTANCE_ID" \
  --iam-instance-profile Name="$EC2_ROLE_NAME"

If an instance profile is already attached, use EC2 Console → Instance → Actions → Security → Modify IAM role instead.

AmazonSSMManagedInstanceCore supplies the standard SSM managed-instance permissions. The repository’s CloudFormation stack later attaches narrowly scoped S3 and Secrets Manager permissions. AWS SSM instance permissions documentation.

3. Require IMDSv2

Because this host runs containers, use a metadata hop limit of 2:

aws ec2 modify-instance-metadata-options \
  --region "$AWS_REGION" \
  --instance-id "$INSTANCE_ID" \
  --http-endpoint enabled \
  --http-tokens required \
  --http-put-response-hop-limit 2

Verify:

aws ec2 describe-instances \
  --region "$AWS_REGION" \
  --instance-ids "$INSTANCE_ID" \
  --query 'Reservations[0].Instances[0].MetadataOptions'

AWS recommends hop limit 2 for container hosts using IMDSv2. EC2 metadata configuration.

4. Configure EC2 networking

The development host should have:

  • Inbound TCP 80 from the internet for Let’s Encrypt HTTP validation and HTTP redirects.
  • Inbound TCP 443 from the internet.
  • No public port 22 if administration is exclusively through SSM.
  • Outbound TCP 443 for AWS APIs, SSM, GHCR and image downloads.
  • An encrypted EBS volume.
  • A public subnet and internet gateway because Traefik is directly internet-facing.

Example security-group rules:

export SECURITY_GROUP_ID=sg-REPLACE_ME

aws ec2 authorize-security-group-ingress \
  --region "$AWS_REGION" \
  --group-id "$SECURITY_GROUP_ID" \
  --protocol tcp \
  --port 80 \
  --cidr 0.0.0.0/0

aws ec2 authorize-security-group-ingress \
  --region "$AWS_REGION" \
  --group-id "$SECURITY_GROUP_ID" \
  --protocol tcp \
  --port 443 \
  --cidr 0.0.0.0/0

Add equivalent ::/0 rules only if the instance and DNS support IPv6.

Remove SSH ingress after SSM access is confirmed:

aws ec2 revoke-security-group-ingress \
  --region "$AWS_REGION" \
  --group-id "$SECURITY_GROUP_ID" \
  --protocol tcp \
  --port 22 \
  --cidr 0.0.0.0/0

5. Associate an Elastic IP

Allocate an address:

export EIP_ALLOCATION_ID="$(
  aws ec2 allocate-address \
    --region "$AWS_REGION" \
    --domain vpc \
    --query AllocationId \
    --output text
)"

aws ec2 associate-address \
  --region "$AWS_REGION" \
  --instance-id "$INSTANCE_ID" \
  --allocation-id "$EIP_ALLOCATION_ID"

export DEVELOPMENT_PUBLIC_IP="$(
  aws ec2 describe-addresses \
    --region "$AWS_REGION" \
    --allocation-ids "$EIP_ALLOCATION_ID" \
    --query 'Addresses[0].PublicIp' \
    --output text
)"

printf '%s\n' "$DEVELOPMENT_PUBLIC_IP"

An Elastic IP prevents the DNS target from changing after instance stop/start. Route 53 EC2 routing guidance.

6. Create DNS records

In Route 53 → Hosted zones → dichit.money, create these A records:

Record Value
api.dev.dichit.money Development Elastic IP
traefik.dev.dichit.money Development Elastic IP

Use a TTL of 300.

Verify:

dig +short api.dev.dichit.money
dig +short traefik.dev.dichit.money

Both should return $DEVELOPMENT_PUBLIC_IP.

7. Install and verify SSM Agent

Connect temporarily through the EC2 console or existing SSH access.

On EC2:

sudo snap list amazon-ssm-agent
sudo snap start amazon-ssm-agent
sudo snap services amazon-ssm-agent

If it is missing:

sudo snap install amazon-ssm-agent --classic
sudo snap start amazon-ssm-agent

Ubuntu AWS AMIs commonly include SSM Agent already. Official Ubuntu SSM Agent instructions.

From your workstation, verify registration:

aws ssm describe-instance-information \
  --region "$AWS_REGION" \
  --filters "Key=InstanceIds,Values=$INSTANCE_ID"

The instance should show PingStatus as Online.

8. Install Docker on EC2

On EC2:

sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg unzip
sudo install -m 0755 -d /etc/apt/keyrings

curl -fsSL https://download.docker.com/linux/ubuntu/gpg |
  sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

sudo chmod a+r /etc/apt/keyrings/docker.gpg

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" |
  sudo tee /etc/apt/sources.list.d/docker.list >/dev/null

sudo apt-get update

sudo apt-get install -y \
  docker-ce \
  docker-ce-cli \
  containerd.io \
  docker-buildx-plugin \
  docker-compose-plugin

sudo systemctl enable --now docker

Verify:

sudo docker version
sudo docker compose version
sudo docker run --rm hello-world

These packages follow Docker’s official Ubuntu installation procedure. Docker Engine installation and Compose plugin installation.

9. Install AWS CLI v2 on EC2

On EC2 x86-64:

cd /tmp

curl -fsSLo awscliv2.zip \
  https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip

unzip -q awscliv2.zip
sudo ./aws/install

aws --version

For an ARM/Graviton instance, use:

https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip

AWS CLI v2 installation documentation.

Do not run aws configure on EC2. It should obtain temporary credentials from the instance profile.

Verify:

aws sts get-caller-identity

10. Authenticate the EC2 Docker daemon to GHCR

For a private GHCR package, create a GitHub classic PAT with only read:packages. Authorize organization SSO if your organization requires it. GitHub currently documents classic PAT authentication for command-line GHCR access. GitHub Container Registry authentication.

On EC2:

read -rsp 'GHCR read token: ' GHCR_READ_TOKEN
printf '\n'

printf '%s' "$GHCR_READ_TOKEN" |
  sudo docker login ghcr.io \
    --username YOUR_GITHUB_USERNAME \
    --password-stdin

unset GHCR_READ_TOKEN

SSM commands run as root, so authenticating using sudo docker login ensures root’s Docker configuration can pull the private images.

11. Prepare the host directory

On EC2:

sudo install -d -m 0750 /opt/dichit-dev
sudo install -d -m 0750 /opt/dichit-dev/traefik
sudo install -d -m 0750 /opt/dichit-dev/scripts

The deployment workflow will populate this directory.

12. Create the application environment secret

On your administrator workstation, create a complete .env.dev. Start from the repository’s environment examples and ensure container-specific values include:

NODE_ENV=development
PORT=3500

REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=GENERATE_A_LONG_RANDOM_VALUE

DATABASE_URL=postgresql://...
DIRECT_URL=postgresql://...

SENTRY_ENVIRONMENT=development

Generate secrets locally:

openssl rand -base64 48

Protect the file:

chmod 0600 .env.dev

Create the secret:

aws secretsmanager create-secret \
  --region "$AWS_REGION" \
  --name /dichit/development/app-env \
  --description 'Dichit development dotenv payload' \
  --secret-string file://.env.dev

Capture its ARN:

export APP_ENV_SECRET_ARN="$(
  aws secretsmanager describe-secret \
    --region "$AWS_REGION" \
    --secret-id /dichit/development/app-env \
    --query ARN \
    --output text
)"

If it already exists, update it with:

aws secretsmanager put-secret-value \
  --region "$AWS_REGION" \
  --secret-id /dichit/development/app-env \
  --secret-string file://.env.dev

Delete the local plaintext file after validating the secret:

aws secretsmanager get-secret-value \
  --region "$AWS_REGION" \
  --secret-id /dichit/development/app-env \
  --query ARN \
  --output text

rm .env.dev

Secrets Manager accepts a file as SecretString, and the initial value receives the AWSCURRENT staging label. AWS Secrets Manager CLI documentation.

13. Create the Traefik dashboard secret

Install htpasswd locally if necessary:

sudo apt-get install -y apache2-utils

Create a bcrypt password entry:

umask 077
dashboard_auth_file="$(mktemp)"

htpasswd -cB "$dashboard_auth_file" devadmin

Create the secret:

aws secretsmanager create-secret \
  --region "$AWS_REGION" \
  --name /dichit/development/traefik-dashboard-auth \
  --description 'Traefik development dashboard htpasswd users' \
  --secret-string "file://${dashboard_auth_file}"

Capture the ARN and remove the temporary file:

export DASHBOARD_AUTH_SECRET_ARN="$(
  aws secretsmanager describe-secret \
    --region "$AWS_REGION" \
    --secret-id /dichit/development/traefik-dashboard-auth \
    --query ARN \
    --output text
)"

rm -f "$dashboard_auth_file"

14. Configure the GitHub Actions OIDC provider

Check whether the account already has the provider:

export AWS_ACCOUNT_ID="$(
  aws sts get-caller-identity \
    --query Account \
    --output text
)"

export GITHUB_OIDC_PROVIDER_ARN="arn:aws:iam::${AWS_ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com"

aws iam get-open-id-connect-provider \
  --open-id-connect-provider-arn "$GITHUB_OIDC_PROVIDER_ARN"

If it does not exist:

  1. Open AWS IAM → Identity providers.
  2. Choose Add provider.
  3. Provider type: OpenID Connect.
  4. Provider URL: https://token.actions.githubusercontent.com.
  5. Audience: sts.amazonaws.com.
  6. Create the provider.

AWS recommends restricting GitHub OIDC roles through the token’s repository, branch or environment subject claim. The repository template restricts it to:

repo:dichit-money/dichit-backend:environment:development

AWS GitHub OIDC guidance.

15. Choose immutable Traefik and Redis images

Use reviewed versions and resolve them to registry digests. For example:

docker pull traefik:YOUR_APPROVED_VERSION
docker image inspect \
  --format '{{index .RepoDigests 0}}' \
  traefik:YOUR_APPROVED_VERSION

docker pull redis:YOUR_APPROVED_VERSION
docker image inspect \
  --format '{{index .RepoDigests 0}}' \
  redis:YOUR_APPROVED_VERSION

Save the resulting values:

export TRAEFIK_IMAGE='traefik@sha256:REPLACE_ME'
export REDIS_IMAGE='redis@sha256:REPLACE_ME'

Do not use mutable values such as traefik:latest or redis:latest.

16. Deploy the AWS deployment stack

From the repository root:

aws cloudformation deploy \
  --region "$AWS_REGION" \
  --stack-name dichit-development-deployment \
  --template-file infra/aws/dev/deployment.yml \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides \
    GitHubOidcProviderArn="$GITHUB_OIDC_PROVIDER_ARN" \
    GitHubRepository="$GITHUB_REPOSITORY" \
    DevelopmentInstanceId="$INSTANCE_ID" \
    DockerHostRoleName="$EC2_ROLE_NAME" \
    AppEnvironmentSecretArn="$APP_ENV_SECRET_ARN" \
    DashboardAuthSecretArn="$DASHBOARD_AUTH_SECRET_ARN"

Capture its outputs:

export DEV_DEPLOYMENT_BUCKET="$(
  aws cloudformation describe-stacks \
    --region "$AWS_REGION" \
    --stack-name dichit-development-deployment \
    --query "Stacks[0].Outputs[?OutputKey=='DeploymentBucketName'].OutputValue" \
    --output text
)"

export AWS_DEV_DEPLOY_ROLE_ARN="$(
  aws cloudformation describe-stacks \
    --region "$AWS_REGION" \
    --stack-name dichit-development-deployment \
    --query "Stacks[0].Outputs[?OutputKey=='GitHubDeploymentRoleArn'].OutputValue" \
    --output text
)"

Verify that the EC2 role received the generated inline policy:

aws iam list-role-policies \
  --role-name "$EC2_ROLE_NAME"

17. Configure the GitHub development environment

In GitHub:

  1. Open repository → Settings → Environments.
  2. Create or open development.
  3. Limit deployment branches to dev.
  4. Optionally require reviewers and prevent self-approval.
  5. Add the following secret:
Secret Value
AWS_DEV_DEPLOY_ROLE_ARN $AWS_DEV_DEPLOY_ROLE_ARN

Add these environment variables:

Variable Value
AWS_REGION ap-south-1
DEV_DEPLOYMENT_BUCKET $DEV_DEPLOYMENT_BUCKET
DEV_INSTANCE_ID $INSTANCE_ID
DEV_APP_ENV_SECRET_ID $APP_ENV_SECRET_ARN
DEV_TRAEFIK_DASHBOARD_AUTH_SECRET_ID $DASHBOARD_AUTH_SECRET_ARN
DEV_TRAEFIK_IMAGE Immutable Traefik digest
DEV_REDIS_IMAGE Immutable Redis digest

GitHub environments can restrict deployment branches and require approval before environment secrets are exposed. GitHub environment protection rules.

18. Update the Traefik ACME contact

Before deploying, verify the email in:

traefik.yml

It should be an operational email monitored by the team:

certificatesResolvers:
  letsencrypt:
    acme:
      email: infrastructure@dichit.money

Commit and push this change before the first deployment if the existing address is not appropriate.

19. Deploy the backup stack

aws cloudformation deploy \
  --region "$AWS_REGION" \
  --stack-name dichit-development-backup \
  --template-file infra/aws/dev/backup.yml \
  --capabilities CAPABILITY_NAMED_IAM

Find attached EBS volumes:

aws ec2 describe-volumes \
  --region "$AWS_REGION" \
  --filters "Name=attachment.instance-id,Values=$INSTANCE_ID" \
  --query 'Volumes[].{VolumeId:VolumeId,Encrypted:Encrypted,Size:Size}' \
  --output table

Tag every EBS volume containing /var/lib/docker:

aws ec2 create-tags \
  --region "$AWS_REGION" \
  --resources vol-REPLACE_ME \
  --tags \
    Key=Backup,Value=dichit-development \
    Key=Application,Value=dichit-backend \
    Key=Environment,Value=development

AWS Backup can select EBS resources using tags, as configured by the repository template. AWS Backup tag selections.

After the first scheduled backup:

aws backup list-protected-resources \
  --region "$AWS_REGION"

Perform at least one restore drill before treating the backup as reliable. AWS Backup’s EBS backups are crash-consistent snapshots; an external PostgreSQL/RDS database needs its own automated backup configuration. AWS EBS Backup documentation.

20. Trigger the first deployment

Push a commit to dev, or run:

gh workflow run cd-dev.yml --ref dev

Watch it:

gh run watch

The workflow should:

  1. Build application and migration images.
  2. Publish immutable digests to GHCR.
  3. Upload the deployment bundle to S3.
  4. Invoke the EC2 host through SSM.
  5. retrieve both Secrets Manager secrets.
  6. start Traefik and Redis.
  7. run database migrations.
  8. deploy and health-check the application.

21. Verify the deployment

Confirm SSM:

aws ssm describe-instance-information \
  --region "$AWS_REGION" \
  --filters "Key=InstanceIds,Values=$INSTANCE_ID"

Start a Session Manager shell:

aws ssm start-session \
  --region "$AWS_REGION" \
  --target "$INSTANCE_ID"

On EC2:

cd /opt/dichit-dev
source .deployment-images.env

docker compose \
  -f docker-compose.dev.yml \
  --env-file .env.dev \
  ps

docker compose \
  -f docker-compose.dev.yml \
  --env-file .env.dev \
  logs --tail 200 app traefik redis

Public verification:

curl --fail https://api.dev.dichit.money/health

curl --head https://traefik.dev.dichit.money/dashboard/
# Expected: 401 Unauthorized

curl --user devadmin \
  https://traefik.dev.dichit.money/dashboard/
# Enter the dashboard password

Finally, restart the EC2 instance once and verify Docker, Traefik, Redis and the application recover automatically.