58.1 Project 17: Every language in a container with AWS RDS MySQL (Node, Python, Go, Java, .NET, PHP)
Why: the problem
Suppose we work at Swiggy and six teams write services in six languages: Node.js, Python, Go, Java, .NET and PHP. In chapter 57 every project ran MySQL in a container next to the app. That is perfect on a laptop. In real production, Swiggy does not want to babysit database containers: backups, patches, failover and disk growth are hard. So the database moves to Amazon RDS, a managed MySQL. AWS does backups, patching and Multi-AZ failover. The apps stay in containers on an EC2 instance. Only one thing changes: the database host becomes the RDS endpoint. The big new questions are: how does the container get the password without putting it in Git, how does traffic reach RDS safely, and how do we stop the AWS bill. We answer all three. The password lives in AWS SSM Parameter Store as a SecureString. The EC2 instance has an IAM role that can read only that parameter. RDS sits in a security group that accepts port 3306 only from the EC2 security group. Then we connect one small menu API in every language to the same RDS database, all from containers, with TLS on. At the end we delete RDS, the parameter and the role, so the free tier stays free.
What: every term in plain English
- Amazon RDS — Relational Database Service. AWS runs MySQL, Postgres and others for us, with backups and patches.
- RDS endpoint — The DNS name of the database, like menu-db.abc123.ap-south-1.rds.amazonaws.com. Apps use it as DB_HOST.
- db.t3.micro / db.t4g.micro — Small instance classes covered by the RDS free tier for 12 months (750 hours a month, 20 GB).
- Security group — A firewall around an AWS resource. We allow 3306 into RDS only from the EC2 security group.
- SSM Parameter Store — An AWS key-value store. A SecureString parameter is encrypted with KMS. Standard parameters are free.
- IAM role / instance profile — An identity attached to EC2. Code on the instance gets short-lived credentials automatically. No access keys in files.
- IMDSv2 hop limit — Containers reach the EC2 metadata service through one extra network hop, so the hop limit must be 2.
- RDS CA bundle — The certificate file (global-bundle.pem) that apps use to verify TLS to RDS.
How: step by step
- Create the security groups and RDS MySQL. Free-tier class, no public access.
- Store the password in SSM. SecureString /swiggy/menu/db_password.
- Create the IAM role for EC2. Read only that parameter.
- Load the schema once. A throwaway mysql:8.4 client container.
- Run the API in each language. Same env vars, same table, TLS on.
- Test every language. curl each port and compare.
- Clean up AWS. Delete RDS, parameter, role and groups.
Architecture
Folder layout
rds-langs/
aws/01-network.sh
aws/02-rds.sh
aws/03-role.json
aws/04-role.sh
aws/99-cleanup.sh
sql/schema.sql
certs/ (global-bundle.pem, rds-truststore.p12)
env.sh
load-schema.sh
.env (created by env.sh, never in Git)
node/ python/ go/ java/ dotnet/ php/
compose.yaml
Example 1: Create RDS MySQL, the security groups, the SSM secret and the IAM role with the AWS CLI
Goal. Create RDS MySQL, the security groups, the SSM secret and the IAM role with the AWS CLI.
aws/01-network.sh
# Run on your laptop or CloudShell. Region: Mumbai.
export AWS_REGION=ap-south-1
VPC=$(aws ec2 describe-vpcs --filters Name=isDefault,Values=true --query 'Vpcs[0].VpcId' --output text)
# Security group for the EC2 Docker host
APP_SG=$(aws ec2 create-security-group --group-name app-sg --description "docker host" --vpc-id $VPC --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id $APP_SG --protocol tcp --port 22 --cidr 203.0.113.10/32
# Security group for RDS: 3306 only from app-sg, never from 0.0.0.0/0
DB_SG=$(aws ec2 create-security-group --group-name db-sg --description "rds mysql" --vpc-id $VPC --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id $DB_SG --protocol tcp --port 3306 --source-group $APP_SG
echo "APP_SG=$APP_SG DB_SG=$DB_SG"
aws/02-rds.sh
# Password goes to SSM first. Replace with your own long random value.
aws ssm put-parameter --name /swiggy/menu/db_password --type SecureString \
--value 'CHANGE_ME_long_random_password_32chars'
aws rds create-db-instance \
--db-instance-identifier menu-db \
--engine mysql --engine-version 8.0 \
--db-instance-class db.t4g.micro \
--allocated-storage 20 --storage-type gp3 \
--master-username admin \
--master-user-password 'CHANGE_ME_long_random_password_32chars' \
--vpc-security-group-ids $DB_SG \
--no-publicly-accessible \
--backup-retention-period 1 \
--no-multi-az
aws rds wait db-instance-available --db-instance-identifier menu-db
aws rds describe-db-instances --db-instance-identifier menu-db \
--query 'DBInstances[0].Endpoint.Address' --output text
aws/03-role.json
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow",
"Action": ["ssm:GetParameter"],
"Resource": "arn:aws:ssm:ap-south-1:111122223333:parameter/swiggy/menu/*" },
{ "Effect": "Allow",
"Action": ["kms:Decrypt"],
"Resource": "*",
"Condition": { "StringEquals": { "kms:ViaService": "ssm.ap-south-1.amazonaws.com" } } }
]
}
aws/04-role.sh
aws iam create-role --role-name docker-host-role \
--assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam put-role-policy --role-name docker-host-role --policy-name read-menu-secret \
--policy-document file://aws/03-role.json
aws iam create-instance-profile --instance-profile-name docker-host-profile
aws iam add-role-to-instance-profile --instance-profile-name docker-host-profile --role-name docker-host-role
# Attach to the EC2 Docker host and let containers reach the metadata service
aws ec2 associate-iam-instance-profile --instance-id i-0abc1234def567890 \
--iam-instance-profile Name=docker-host-profile
aws ec2 modify-instance-metadata-options --instance-id i-0abc1234def567890 \
--http-tokens required --http-put-response-hop-limit 2
What each line does
--source-group $APP_SG— Only machines in app-sg can open port 3306. Safer than any IP range.--no-publicly-accessible— RDS gets only a private IP. Nobody on the internet can even try to connect.db.t4g.micro + 20 GB gp3— Free-tier sizes. Bigger classes cost money from the first hour.--backup-retention-period 1— Keeps one day of automatic backups, inside the free backup storage.SecureString— SSM encrypts the value with the AWS managed KMS key.hop-limit 2— Without it, the SDK inside a container times out when it asks for role credentials.
Green output
$ aws rds wait db-instance-available --db-instance-identifier menu-db
$ aws rds describe-db-instances --db-instance-identifier menu-db --query 'DBInstances[0].Endpoint.Address' --output text
menu-db.c9xyzabc1234.ap-south-1.rds.amazonaws.com
$ aws ssm get-parameter --name /swiggy/menu/db_password --with-decryption --query Parameter.Value --output text
CHANGE_ME_long_random_password_32chars
Expected result. We have a private RDS MySQL, a secret in SSM and an EC2 role that can read only that secret.
Example 2: On the EC2 host: load the schema with a throwaway client container, then run Node
Goal. On the EC2 host: load the schema with a throwaway client container, then run Node.js and Python against RDS.
sql/schema.sql
CREATE DATABASE IF NOT EXISTS menu;
USE menu;
CREATE TABLE IF NOT EXISTS dishes (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
price INT NOT NULL,
lang VARCHAR(20) NOT NULL DEFAULT 'seed'
);
INSERT INTO dishes (name, price) VALUES ('Misal Pav', 90), ('Vada Pav', 25);
env.sh
# Builds .env from SSM using the instance role. The .env file is in .gitignore.
export AWS_REGION=ap-south-1
DB_PASSWORD=$(aws ssm get-parameter --name /swiggy/menu/db_password --with-decryption --query Parameter.Value --output text)
{
echo "DB_HOST=menu-db.c9xyzabc1234.ap-south-1.rds.amazonaws.com"
echo "DB_USER=admin"
echo "DB_PASSWORD=$DB_PASSWORD"
echo "DB_NAME=menu"
} > .env
curl -sS -o certs/global-bundle.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
load-schema.sh
docker run --rm --env-file .env -v "$PWD/sql":/sql:ro -v "$PWD/certs":/certs:ro mysql:8.4 \
sh -c 'mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" --ssl-ca=/certs/global-bundle.pem --ssl-mode=VERIFY_IDENTITY -e "source /sql/schema.sql"'
node/index.js
const fs = require("fs");
const express = require("express");
const mysql = require("mysql2/promise");
const pool = mysql.createPool({
host: process.env.DB_HOST, user: process.env.DB_USER,
password: process.env.DB_PASSWORD, database: process.env.DB_NAME,
ssl: { ca: fs.readFileSync("/certs/global-bundle.pem") },
connectionLimit: 5
});
const app = express();
app.use(express.json());
app.get("/healthz", async (req, res) => { await pool.query("SELECT 1"); res.send("ok"); });
app.get("/dishes", async (req, res) => {
const [rows] = await pool.query("SELECT id, name, price, lang FROM dishes ORDER BY id");
res.json(rows);
});
app.post("/dishes", async (req, res) => {
const [r] = await pool.query("INSERT INTO dishes (name, price, lang) VALUES (?, ?, 'node')", [req.body.name, req.body.price]);
res.status(201).json({ id: r.insertId });
});
app.listen(3000, () => console.log("node menu api on 3000"));
node/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY index.js .
USER node
EXPOSE 3000
CMD ["node", "index.js"]
python/app.py
import os
import pymysql
from fastapi import FastAPI
from pydantic import BaseModel
def conn():
return pymysql.connect(
host=os.environ["DB_HOST"], user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"], database=os.environ["DB_NAME"],
ssl={"ca": "/certs/global-bundle.pem"},
cursorclass=pymysql.cursors.DictCursor)
app = FastAPI()
class Dish(BaseModel):
name: str
price: int
@app.get("/healthz")
def health():
with conn() as c, c.cursor() as cur:
cur.execute("SELECT 1")
return "ok"
@app.get("/dishes")
def dishes():
with conn() as c, c.cursor() as cur:
cur.execute("SELECT id, name, price, lang FROM dishes ORDER BY id")
return cur.fetchall()
@app.post("/dishes", status_code=201)
def add(d: Dish):
with conn() as c, c.cursor() as cur:
cur.execute("INSERT INTO dishes (name, price, lang) VALUES (%s, %s, 'python')", (d.name, d.price))
c.commit()
return {"id": cur.lastrowid}
python/Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir fastapi==0.115.2 uvicorn==0.32.0 pymysql==1.1.1 cryptography==43.0.3
COPY app.py .
RUN useradd -r app
USER app
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
What each line does
env.sh— Turns the SSM secret into a local .env file at start time. The password never enters Git or the image.--env-file .env— Passes the four DB variables into the container.--ssl-mode=VERIFY_IDENTITY— The client checks the RDS certificate and the host name. No man-in-the-middle.ssl: { ca: ... }— Node and Python verify TLS with the same RDS CA bundle mounted at /certs.connectionLimit: 5— db.t4g.micro allows only about 60 connections. Six services must share them.lang column— Each language writes its own name, so we can see who wrote which row.
Green output
$ bash env.sh && bash load-schema.sh
$ docker build -t menu-node ./node && docker build -t menu-python ./python
$ docker run -d --name node --env-file .env -v "$PWD/certs":/certs:ro -p 3000:3000 menu-node
$ docker run -d --name python --env-file .env -v "$PWD/certs":/certs:ro -p 8000:8000 menu-python
$ curl -s localhost:3000/dishes
[{"id":1,"name":"Misal Pav","price":90,"lang":"seed"},{"id":2,"name":"Vada Pav","price":25,"lang":"seed"}]
$ curl -s -X POST localhost:8000/dishes -H 'content-type: application/json' -d '{"name":"Poha","price":40}'
{"id":3}
Expected result. Node and Python containers share one managed RDS database over TLS, with the password read from SSM.
Example 3: Go, Java Spring Boot and
Goal. Go, Java Spring Boot and .NET 8 against the same RDS.
go/main.go
package main
import (
"crypto/tls"
"crypto/x509"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"github.com/go-sql-driver/mysql"
)
type Dish struct {
ID int `json:"id"`
Name string `json:"name"`
Price int `json:"price"`
Lang string `json:"lang"`
}
func main() {
pem, _ := os.ReadFile("/certs/global-bundle.pem")
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(pem)
mysql.RegisterTLSConfig("rds", &tls.Config{RootCAs: pool, ServerName: os.Getenv("DB_HOST")})
dsn := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s?tls=rds",
os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_HOST"), os.Getenv("DB_NAME"))
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
db.SetMaxOpenConns(5)
http.HandleFunc("/dishes", func(w http.ResponseWriter, r *http.Request) {
rows, err := db.Query("SELECT id, name, price, lang FROM dishes ORDER BY id")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer rows.Close()
out := []Dish{}
for rows.Next() {
var d Dish
rows.Scan(&d.ID, &d.Name, &d.Price, &d.Lang)
out = append(out, d)
}
json.NewEncoder(w).Encode(out)
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
java/src/main/resources/application.properties
spring.datasource.url=jdbc:mysql://${DB_HOST}:3306/${DB_NAME}?sslMode=VERIFY_IDENTITY&trustCertificateKeyStoreUrl=file:/certs/rds-truststore.p12&trustCertificateKeyStoreType=PKCS12&trustCertificateKeyStorePassword=changeit
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.hikari.maximum-pool-size=5
server.port=8081
java/src/main/java/com/swiggy/menu/MenuController.java
package com.swiggy.menu;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
@RestController
public class MenuController {
private final JdbcTemplate jdbc;
public MenuController(JdbcTemplate jdbc) { this.jdbc = jdbc; }
@GetMapping("/dishes")
public Object dishes() {
return jdbc.queryForList("SELECT id, name, price, lang FROM dishes ORDER BY id");
}
}
java/Dockerfile
FROM gradle:8.10-jdk21 AS build
WORKDIR /src
COPY . .
RUN gradle bootJar --no-daemon
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /src/build/libs/*.jar app.jar
USER 1000
EXPOSE 8081
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=60", "-jar", "app.jar"]
dotnet/Program.cs
using MySqlConnector;
var b = WebApplication.CreateBuilder(args);
var app = b.Build();
var cs = $"Server={Environment.GetEnvironmentVariable("DB_HOST")};" +
$"Database={Environment.GetEnvironmentVariable("DB_NAME")};" +
$"User ID={Environment.GetEnvironmentVariable("DB_USER")};" +
$"Password={Environment.GetEnvironmentVariable("DB_PASSWORD")};" +
"SslMode=VerifyFull;SslCa=/certs/global-bundle.pem;Maximum Pool Size=5";
app.MapGet("/dishes", async () =>
{
await using var conn = new MySqlConnection(cs);
await conn.OpenAsync();
await using var cmd = new MySqlCommand("SELECT id, name, price, lang FROM dishes ORDER BY id", conn);
await using var r = await cmd.ExecuteReaderAsync();
var list = new System.Collections.ArrayList();
while (await r.ReadAsync())
list.Add(new { id = r.GetInt32(0), name = r.GetString(1), price = r.GetInt32(2), lang = r.GetString(3) });
return Results.Ok(list);
});
app.Run("http://0.0.0.0:8082");
What each line does
RegisterTLSConfig("rds", ...)— Go needs a named TLS config with the RDS CA pool, then tls=rds in the DSN.SetMaxOpenConns(5)— Same pool limit in every language so six services fit in a micro instance.sslMode=VERIFY_IDENTITY— The MySQL Connector/J setting for full TLS checks. It reads a PKCS12 trust store made from the bundle.hikari.maximum-pool-size=5— Spring Boot's pool limit. The default is 10.SslMode=VerifyFull;SslCa=...— MySqlConnector for .NET verifies the RDS certificate and host name.ArrayList— We avoid generic collections to keep the code short; the JSON output is the same.
Green output
$ docker run --rm -v "$PWD/certs":/certs eclipse-temurin:21-jre keytool -importcert -noprompt -alias rds -file /certs/global-bundle.pem -keystore /certs/rds-truststore.p12 -storetype PKCS12 -storepass changeit
Certificate was added to keystore
$ docker run -d --name go --env-file .env -v "$PWD/certs":/certs:ro -p 8080:8080 menu-go
$ docker run -d --name java --env-file .env -v "$PWD/certs":/certs:ro -p 8081:8081 menu-java
$ docker run -d --name dotnet --env-file .env -v "$PWD/certs":/certs:ro -p 8082:8082 menu-dotnet
$ for p in 8080 8081 8082; do curl -s localhost:$p/dishes | head -c 60; echo; done
[{"id":1,"name":"Misal Pav","price":90,"lang":"seed"},{"id
[{"id":1,"name":"Misal Pav","price":90,"lang":"seed"},{"id
[{"id":1,"name":"Misal Pav","price":90,"lang":"seed"},{"id
Expected result. Go, Java and .NET read the same RDS rows with full TLS and small pools.
Example 4: PHP, one Compose file for all six services, a connection check, and the AWS cleanup
Goal. PHP, one Compose file for all six services, a connection check, and the AWS cleanup.
php/index.php
<?php
$dsn = sprintf('mysql:host=%s;dbname=%s', getenv('DB_HOST'), getenv('DB_NAME'));
$pdo = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASSWORD'), [
PDO::MYSQL_ATTR_SSL_CA => '/certs/global-bundle.pem',
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
header('Content-Type: application/json');
echo json_encode($pdo->query('SELECT id, name, price, lang FROM dishes ORDER BY id')->fetchAll(PDO::FETCH_ASSOC));
php/Dockerfile
FROM php:8.3-apache
RUN docker-php-ext-install pdo_mysql
COPY index.php /var/www/html/index.php
EXPOSE 80
compose.yaml
x-app: &app
env_file: .env
volumes:
- ./certs:/certs:ro
restart: unless-stopped
logging:
driver: awslogs
options:
awslogs-region: ap-south-1
awslogs-group: /swiggy/menu
awslogs-create-group: "true"
services:
node: { <<: *app, build: ./node, ports: ["3000:3000"] }
python: { <<: *app, build: ./python, ports: ["8000:8000"] }
go: { <<: *app, build: ./go, ports: ["8080:8080"] }
java: { <<: *app, build: ./java, ports: ["8081:8081"] }
dotnet: { <<: *app, build: ./dotnet, ports: ["8082:8082"] }
php: { <<: *app, build: ./php, ports: ["8083:80"] }
aws/99-cleanup.sh
docker compose down --rmi local
aws rds delete-db-instance --db-instance-identifier menu-db --skip-final-snapshot --delete-automated-backups
aws rds wait db-instance-deleted --db-instance-identifier menu-db
aws ssm delete-parameter --name /swiggy/menu/db_password
aws ec2 delete-security-group --group-id $DB_SG
aws iam remove-role-from-instance-profile --instance-profile-name docker-host-profile --role-name docker-host-role
aws iam delete-instance-profile --instance-profile-name docker-host-profile
aws iam delete-role-policy --role-name docker-host-role --policy-name read-menu-secret
aws iam delete-role --role-name docker-host-role
What each line does
x-app: &app— A YAML anchor. All six services share env, certs, restart and logging.PDO::MYSQL_ATTR_SSL_CA— PHP's way to turn on TLS with the RDS bundle.awslogs driver— Container logs go straight to CloudWatch Logs. The instance role also needs logs:CreateLogStream and logs:PutLogEvents.--skip-final-snapshot— For practice only. In real work keep a final snapshot.--delete-automated-backups— Removes the backups so nothing keeps billing.Order of cleanup— RDS first, then the security group it uses, then the role.
Green output
$ docker compose up -d --build
Container rds-langs-php-1 Started
Container rds-langs-dotnet-1 Started
$ docker compose exec python python -c "import pymysql;print('tls ok')"
tls ok
$ curl -s localhost:8083 | head -c 50
[{"id":1,"name":"Misal Pav","price":90,"lang":"se
$ bash aws/99-cleanup.sh
$ aws rds describe-db-instances --query 'DBInstances[].DBInstanceIdentifier'
[]
Expected result. All six languages run from one Compose file against RDS, logs go to CloudWatch, and the cleanup leaves no bill.
Every command, step by step (plain docker and Compose)
-
Network and RDS
bash aws/01-network.sh && bash aws/02-rds.shWhat it does: Creates two security groups, the SSM secret and a free-tier RDS. Takes 5 to 10 minutes.
menu-db.c9xyzabc1234.ap-south-1.rds.amazonaws.com -
Role
bash aws/04-role.shWhat it does: Gives the EC2 host a role that can read only /swiggy/menu/*.
(no output) -
Check the role from a container
docker run --rm amazon/aws-cli sts get-caller-identityWhat it does: The CLI inside a container uses the instance role through IMDSv2.
"Arn": "arn:aws:sts::111122223333:assumed-role/docker-host-role/i-0abc1234def567890" -
Build .env
mkdir -p certs && bash env.shWhat it does: Reads the password from SSM and downloads the RDS CA bundle.
(no output) -
Schema
bash load-schema.shWhat it does: A throwaway mysql:8.4 container runs schema.sql over TLS and is removed.
(no output) -
Trust store for Java
docker run --rm -v "$PWD/certs":/certs eclipse-temurin:21-jre keytool -importcert -noprompt -alias rds -file /certs/global-bundle.pem -keystore /certs/rds-truststore.p12 -storetype PKCS12 -storepass changeitWhat it does: Makes the PKCS12 file that Connector/J reads.
Certificate was added to keystore -
Start all six
docker compose up -d --buildWhat it does: Builds six images and starts them.
Container rds-langs-node-1 Started -
Test every port
for p in 3000 8000 8080 8081 8082 8083; do curl -s localhost:$p/dishes || curl -s localhost:$p; echo; doneWhat it does: Every language returns the same rows.
[{"id":1,"name":"Misal Pav","price":90,"lang":"seed"} ...] -
Count connections
docker run --rm --env-file .env mysql:8.4 sh -c 'mysql -h $DB_HOST -u $DB_USER -p$DB_PASSWORD -e "SHOW STATUS LIKE \"Threads_connected\""'What it does: Shows how many connections the six pools hold.
Threads_connected 14 -
Clean up
bash aws/99-cleanup.sh && rm .envWhat it does: Deletes RDS, backups, parameter, SG and role, then the local secret file.
[]
When it is red 1
ERROR 2003 (HY000): Can't connect to MySQL server on 'menu-db....rds.amazonaws.com:3306' (110)
Cause. Timeout: the RDS security group does not allow the EC2 security group, or the EC2 is in another VPC.
Fix, step by step
- Check db-sg has an inbound rule for 3306 from app-sg.
- Check both are in the same VPC.
- Never fix it by opening 3306 to 0.0.0.0/0.
When it is red 2
Unable to locate credentials. You can configure credentials by running "aws configure".
Cause. The container cannot reach the instance metadata service (IMDSv2 hop limit is 1) or no role is attached.
Fix, step by step
- Run modify-instance-metadata-options with --http-put-response-hop-limit 2.
- Check the instance profile with aws ec2 describe-iam-instance-profile-associations.
When it is red 3
AccessDeniedException: User: ...assumed-role/docker-host-role/... is not authorized to perform: ssm:GetParameter
Cause. The policy resource ARN does not match the parameter name or region.
Fix, step by step
- Fix the Resource ARN to parameter/swiggy/menu/*.
- Add kms:Decrypt for SecureString.
When it is red 4
ERROR 1040 (08004): Too many connections
Cause. Six pools with default sizes exceed max_connections on a micro instance.
Fix, step by step
- Set pool size 5 in every language.
- Or use RDS Proxy in front of RDS for many services.
When it is red 5
SSL connection error: certificate verify failed
Cause. The CA bundle is missing, old, or the host name is an IP address instead of the RDS endpoint.
Fix, step by step
- Download global-bundle.pem again.
- Use the endpoint name in DB_HOST, not the IP.
Practice tasks
Easy Task 1: Endpoint
Why: Start.
What to do: Print the RDS endpoint and status.
- Use one free-tier AWS account, one EC2 instance with Docker, and the AWS CLI v2.
- Create the rds-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
aws rds describe-db-instances --db-instance-identifier menu-db --query 'DBInstances[0].[Endpoint.Address,DBInstanceStatus]'Easy Task 2: Role check
Why: IAM.
What to do: Prove a container uses the instance role.
- Use one free-tier AWS account, one EC2 instance with Docker, and the AWS CLI v2.
- Create the rds-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker run --rm amazon/aws-cli sts get-caller-identityEasy Task 3: Two languages
Why: Basics.
What to do: Run Node and Python and add a dish from each.
- Use one free-tier AWS account, one EC2 instance with Docker, and the AWS CLI v2.
- Create the rds-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker compose up -d node python
curl -s -X POST localhost:3000/dishes -H 'content-type: application/json' -d '{"name":"Pohe","price":30}'Medium Task 4: All six
Why: Matrix.
What to do: Start all six and compare their output.
- Use one free-tier AWS account, one EC2 instance with Docker, and the AWS CLI v2.
- Create the rds-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker compose up -d --build
for p in 3000 8000 8080 8081 8082 8083; do curl -s localhost:$p/dishes; echo; doneMedium Task 5: Rotate
Why: Secrets.
What to do: Change the password in RDS and SSM, rebuild .env and restart.
- Use one free-tier AWS account, one EC2 instance with Docker, and the AWS CLI v2.
- Create the rds-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
aws rds modify-db-instance --db-instance-identifier menu-db --master-user-password 'NEW_placeholder_pw' --apply-immediately
aws ssm put-parameter --name /swiggy/menu/db_password --type SecureString --overwrite --value 'NEW_placeholder_pw'
bash env.sh && docker compose up -d --force-recreateMedium Task 6: Break the SG
Why: Debug.
What to do: Remove the 3306 rule, watch the timeout, then put it back.
- Use one free-tier AWS account, one EC2 instance with Docker, and the AWS CLI v2.
- Create the rds-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
aws ec2 revoke-security-group-ingress --group-id $DB_SG --protocol tcp --port 3306 --source-group $APP_SGMedium Task 7: Logs
Why: CloudWatch.
What to do: Read the Go container logs in CloudWatch.
- Use one free-tier AWS account, one EC2 instance with Docker, and the AWS CLI v2.
- Create the rds-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
aws logs tail /swiggy/menu --followHard Task 8: IAM auth
Why: No password.
What to do: Use generate_db_auth_token in the Python service.
- Use one free-tier AWS account, one EC2 instance with Docker, and the AWS CLI v2.
- Create the rds-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
aws rds generate-db-auth-token --hostname $DB_HOST --port 3306 --username app_iamHard Task 9: Snapshot
Why: Backup.
What to do: Take a manual snapshot and restore it to a new instance.
- Use one free-tier AWS account, one EC2 instance with Docker, and the AWS CLI v2.
- Create the rds-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
aws rds create-db-snapshot --db-instance-identifier menu-db --db-snapshot-identifier menu-snap-1Easy Task 10: Clean
Why: Habit.
What to do: Delete everything and confirm nothing is left.
- Use one free-tier AWS account, one EC2 instance with Docker, and the AWS CLI v2.
- Create the rds-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
bash aws/99-cleanup.sh
aws rds describe-db-instances --query 'DBInstances[].DBInstanceIdentifier'Homework: try at home
Add RDS IAM database authentication: create a MySQL user with AWSAuthenticationPlugin, give the role rds-db:connect, and change the Python service to use a 15-minute token from boto3 generate_db_auth_token instead of a password.
Recap
- Production MySQL moves to RDS; app containers only change DB_HOST.
- RDS stays private; 3306 is open only to the app security group.
- Passwords live in SSM and reach containers through the instance role.
- Set the IMDSv2 hop limit to 2 so containers can use the role.
- Verify TLS with the RDS CA bundle in every language.
- Keep pools small and delete RDS, backups, parameter and role after practice.
FAQ
Q. Is RDS free?
db.t3.micro or db.t4g.micro single-AZ with 20 GB is free for 12 months on a new account. Multi-AZ and bigger classes are not.
Q. Can my laptop connect to private RDS?
Not directly. Use an SSM port-forwarding session through the EC2 host, or a bastion.
Q. Why not put the password in compose.yaml?
compose.yaml goes to Git. The password would leak to everyone with repo access.
Q. Can I use Secrets Manager instead of SSM?
Yes. It adds automatic rotation for RDS but costs about 0.40 USD per secret a month.
Q. What about Aurora?
Aurora is MySQL-compatible. The same containers work by changing only DB_HOST.
Q. Do containers need the AWS CLI?
No. Only env.sh on the host uses it. Apps just read env vars.
Interview questions
-
How do you give a container on EC2 access to AWS without access keys?
Model answer: Attach an IAM role through an instance profile, set the IMDSv2 hop limit to 2, and let the SDK pick up temporary credentials.
-
How do you secure the path between an app and RDS?
Model answer: Private subnet, no public access, security group from the app SG only, TLS with the RDS CA bundle, and IAM or SSM for credentials.
-
Many microservices hit Too many connections on RDS. What do you do?
Model answer: Limit pool sizes, add RDS Proxy, or scale the instance class.
-
How do you rotate the DB password without downtime?
Model answer: Use Secrets Manager rotation or a second user, update the secret, then restart containers one by one.
-
Why move the database out of containers in production?
Model answer: Managed backups, point-in-time restore, Multi-AZ failover and patching are hard to do well with a database container.
Quick quiz
Q1. Why move MySQL from a container to RDS?
AWS handles backups, patches, failover and storage growth. We only run the app containers.
Q2. Where does the password live?
In SSM Parameter Store as a SecureString. A script reads it at start time with the instance role.
Q3. Why hop limit 2?
The Docker bridge adds one network hop between the container and the metadata service.
Q4. How do we stop the bill?
Delete RDS with its automated backups, the parameter, the role and the security groups.
Q5. Why the same pool size in every language?
A micro RDS has few connections, and six services share them.
Samajla ka?
Revision card
Interview card