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.moneyis 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:
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:
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:
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:
AWS CLI v2 installation documentation.
Do not run aws configure on EC2. It should obtain temporary credentials from the instance profile.
Verify:
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:
Protect the file:
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:
Create a bcrypt password entry:
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:
- Open AWS IAM → Identity providers.
- Choose Add provider.
- Provider type:
OpenID Connect. - Provider URL:
https://token.actions.githubusercontent.com. - Audience:
sts.amazonaws.com. - 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:
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:
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:
17. Configure the GitHub development environment¶
In GitHub:
- Open repository → Settings → Environments.
- Create or open
development. - Limit deployment branches to
dev. - Optionally require reviewers and prevent self-approval.
- 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:
It should be an operational email monitored by the team:
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:
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:
Watch it:
The workflow should:
- Build application and migration images.
- Publish immutable digests to GHCR.
- Upload the deployment bundle to S3.
- Invoke the EC2 host through SSM.
- retrieve both Secrets Manager secrets.
- start Traefik and Redis.
- run database migrations.
- 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:
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.