58.2 Project 18: Every language in a container with DynamoDB (DynamoDB Local and LocalStack first, then real DynamoDB with an IAM role)
Why: the problem
Suppose we work at Zomato. The order-tracking team needs a table that answers one question very fast: what is the status of order 1001? There are no joins and no reports. Only get by key, put by key, and a list of orders for one customer. This is a perfect job for Amazon DynamoDB. It is a serverless key-value database. There is no server to patch and no connection pool to size. But we do not want to practise on real AWS every time. Every test would cost money and need internet. So we start on the laptop. Amazon gives an official container called amazon/dynamodb-local. LocalStack gives a bigger fake AWS in one container. Our apps in Node.js, Python, Go, Java, .NET and PHP talk to the local table first. Only one setting changes later: the endpoint URL. When the code works locally, we create the real table on AWS. The EC2 host gets an IAM role that can touch only this one table. No access keys go into any file. At the end we delete the table and the role, so nothing keeps billing.
What: every term in plain English
- Amazon DynamoDB — A serverless NoSQL key-value and document database from AWS. You pay per request or per capacity.
- Partition key and sort key — The primary key. Here pk is CUSTOMER#id and sk is ORDER#id, so one query gets all orders of a customer.
- On-demand mode — PAY_PER_REQUEST billing. No capacity planning. Good for practice and spiky traffic.
- amazon/dynamodb-local — The official DynamoDB container for testing. It listens on port 8000 and stores data in memory or a file.
- LocalStack — One container that fakes many AWS services (DynamoDB, S3, SQS) on port 4566.
- Endpoint override — An SDK setting that points the client to http://dynamodb-local:8000 instead of real AWS.
- Fake credentials — DynamoDB Local accepts any key. We use the words test and test, never real keys.
- Least-privilege policy — The IAM role can call only GetItem, PutItem, Query and UpdateItem on the one table ARN.
How: step by step
- Start DynamoDB Local in Compose. Port 8000, data saved to a volume.
- Create the table with a one-shot CLI container. aws dynamodb create-table against the local endpoint.
- Run the API in each language. Same env vars: TABLE, AWS_REGION, DDB_ENDPOINT.
- Try LocalStack as a second fake AWS. Change only the endpoint.
- Create the real table and IAM role. On-demand table, role limited to the table.
- Run on EC2 without DDB_ENDPOINT. The SDK now uses real AWS and the instance role.
- Clean up. Delete the table, role and local volume.
Architecture
Folder layout
dynamo-langs/
compose.yaml (dynamodb-local + init-table)
compose.local.yaml (apps with DDB_ENDPOINT)
compose.localstack.yaml
compose.aws.yaml (apps without endpoint or keys)
ddb/init.sh
aws/01-table.sh
aws/02-policy.json
aws/03-role.sh
aws/99-cleanup.sh
node/ python/ go/ java/ dotnet/ php/
Example 1: Run DynamoDB Local and create the orders table with a throwaway AWS CLI container
Goal. Run DynamoDB Local and create the orders table with a throwaway AWS CLI container.
compose.yaml
services:
dynamodb-local:
image: amazon/dynamodb-local:2.5.2
command: ["-jar", "DynamoDBLocal.jar", "-sharedDb", "-dbPath", "/data"]
user: root
volumes:
- ddb-data:/data
ports:
- "8000:8000"
healthcheck:
test: ["CMD-SHELL", "curl -s localhost:8000 || exit 1"]
interval: 5s
retries: 10
init-table:
image: amazon/aws-cli:2.17.0
depends_on:
dynamodb-local:
condition: service_started
environment:
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_REGION: ap-south-1
volumes:
- ./ddb:/ddb:ro
entrypoint: ["sh", "/ddb/init.sh"]
restart: "no"
volumes:
ddb-data:
ddb/init.sh
E=http://dynamodb-local:8000
sleep 3
aws dynamodb describe-table --table-name orders --endpoint-url $E >/dev/null 2>&1 && { echo "table exists"; exit 0; }
aws dynamodb create-table --endpoint-url $E \
--table-name orders \
--attribute-definitions AttributeName=pk,AttributeType=S AttributeName=sk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST
aws dynamodb put-item --endpoint-url $E --table-name orders \
--item '{"pk":{"S":"CUSTOMER#7"},"sk":{"S":"ORDER#1001"},"dish":{"S":"Misal Pav"},"status":{"S":"PLACED"},"lang":{"S":"seed"}}'
echo "table created"
What each line does
-sharedDb— All clients share one database file, whatever region or key they send.-dbPath /data + volume— Data survives docker compose down. Without it, data lives only in memory.user: root— The image runs as a user that cannot write to a fresh named volume, so we run as root only in this local test.init-table service— A one-shot container. It creates the table and exits with code 0.AWS_ACCESS_KEY_ID: test— DynamoDB Local does not check keys. Never put real keys here.pk / sk— CUSTOMER#7 and ORDER#1001. A single-table design with prefixes.
Green output
$ docker compose up -d dynamodb-local
$ docker compose run --rm init-table
table created
$ docker run --rm --network dynamo-langs_default -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_REGION=ap-south-1 amazon/aws-cli:2.17.0 dynamodb scan --table-name orders --endpoint-url http://dynamodb-local:8000 --query Count
1
Expected result. A local DynamoDB runs in a container with a real table and one seed order, with zero AWS cost.
Example 2: Node
Goal. Node.js and Python order APIs that read DDB_ENDPOINT, so the same image works locally and on AWS.
node/index.js
const express = require("express");
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const { DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand } = require("@aws-sdk/lib-dynamodb");
const cfg = { region: process.env.AWS_REGION };
if (process.env.DDB_ENDPOINT) cfg.endpoint = process.env.DDB_ENDPOINT;
const db = DynamoDBDocumentClient.from(new DynamoDBClient(cfg));
const TABLE = process.env.TABLE;
const app = express();
app.use(express.json());
app.get("/healthz", (req, res) => res.send("ok"));
app.post("/customers/:c/orders", async (req, res) => {
const item = { pk: "CUSTOMER#" + req.params.c, sk: "ORDER#" + req.body.id,
dish: req.body.dish, status: "PLACED", lang: "node" };
await db.send(new PutCommand({ TableName: TABLE, Item: item,
ConditionExpression: "attribute_not_exists(sk)" }));
res.status(201).json(item);
});
app.get("/customers/:c/orders", async (req, res) => {
const out = await db.send(new QueryCommand({ TableName: TABLE,
KeyConditionExpression: "pk = :p AND begins_with(sk, :o)",
ExpressionAttributeValues: { ":p": "CUSTOMER#" + req.params.c, ":o": "ORDER#" } }));
res.json(out.Items);
});
app.get("/customers/:c/orders/:o", async (req, res) => {
const out = await db.send(new GetCommand({ TableName: TABLE,
Key: { pk: "CUSTOMER#" + req.params.c, sk: "ORDER#" + req.params.o } }));
out.Item ? res.json(out.Item) : res.status(404).send("not found");
});
app.listen(3000, () => console.log("node orders 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 boto3
from boto3.dynamodb.conditions import Key
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
kw = {"region_name": os.environ["AWS_REGION"]}
if os.getenv("DDB_ENDPOINT"):
kw["endpoint_url"] = os.environ["DDB_ENDPOINT"]
table = boto3.resource("dynamodb", **kw).Table(os.environ["TABLE"])
app = FastAPI()
class Order(BaseModel):
id: str
dish: str
@app.get("/healthz")
def health():
return "ok"
@app.post("/customers/{c}/orders", status_code=201)
def add(c: str, o: Order):
item = {"pk": f"CUSTOMER#{c}", "sk": f"ORDER#{o.id}", "dish": o.dish,
"status": "PLACED", "lang": "python"}
table.put_item(Item=item)
return item
@app.patch("/customers/{c}/orders/{o}/status/{s}")
def status(c: str, o: str, s: str):
r = table.update_item(
Key={"pk": f"CUSTOMER#{c}", "sk": f"ORDER#{o}"},
UpdateExpression="SET #s = :s",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={":s": s},
ReturnValues="ALL_NEW")
return r["Attributes"]
@app.get("/customers/{c}/orders")
def list_orders(c: str):
r = table.query(KeyConditionExpression=Key("pk").eq(f"CUSTOMER#{c}") & Key("sk").begins_with("ORDER#"))
return r["Items"]
python/Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir fastapi==0.115.2 uvicorn==0.32.0 boto3==1.35.40
COPY app.py .
RUN useradd -r app
USER app
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
compose.local.yaml
x-ddb: &ddb
environment:
TABLE: orders
AWS_REGION: ap-south-1
DDB_ENDPOINT: http://dynamodb-local:8000
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
depends_on: [dynamodb-local]
restart: unless-stopped
services:
node: { <<: *ddb, build: ./node, ports: ["3000:3000"] }
python: { <<: *ddb, build: ./python, ports: ["8001:8000"] }
What each line does
if (process.env.DDB_ENDPOINT)— Locally we set the endpoint. On AWS we leave it empty and the SDK finds the real service.DynamoDBDocumentClient— Lets Node use plain JSON instead of the typed {"S": ...} format.ConditionExpression attribute_not_exists— Stops a second POST from overwriting the same order.begins_with(sk, :o)— One Query returns every order of a customer, sorted by sk.#s for status— status is a reserved word in DynamoDB, so we use an attribute name placeholder.8001:8000— Port 8000 on the host is already used by DynamoDB Local, so Python is published on 8001.
Green output
$ docker compose -f compose.yaml -f compose.local.yaml up -d --build node python
$ curl -s -X POST localhost:3000/customers/7/orders -H 'content-type: application/json' -d '{"id":"1002","dish":"Vada Pav"}'
{"pk":"CUSTOMER#7","sk":"ORDER#1002","dish":"Vada Pav","status":"PLACED","lang":"node"}
$ curl -s -X PATCH localhost:8001/customers/7/orders/1002/status/DELIVERED
{"pk":"CUSTOMER#7","sk":"ORDER#1002","dish":"Vada Pav","status":"DELIVERED","lang":"node"}
$ curl -s localhost:8001/customers/7/orders | head -c 80
[{"pk":"CUSTOMER#7","sk":"ORDER#1001","dish":"Misal Pav","status":"PLACED"
Expected result. Node writes and Python updates the same local table. One env var decides local or real AWS.
Example 3: Go, Java and
Goal. Go, Java and .NET clients with the same endpoint switch, and LocalStack as a second fake AWS.
go/main.go
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)
func main() {
cfg, err := config.LoadDefaultConfig(context.TODO())
if err != nil {
log.Fatal(err)
}
db := dynamodb.NewFromConfig(cfg, func(o *dynamodb.Options) {
if ep := os.Getenv("DDB_ENDPOINT"); ep != "" {
o.BaseEndpoint = aws.String(ep)
}
})
http.HandleFunc("/orders/1001", func(w http.ResponseWriter, r *http.Request) {
out, err := db.GetItem(r.Context(), &dynamodb.GetItemInput{
TableName: aws.String(os.Getenv("TABLE")),
Key: map[string]types.AttributeValue{
"pk": &types.AttributeValueMemberS{Value: "CUSTOMER#7"},
"sk": &types.AttributeValueMemberS{Value: "ORDER#1001"},
},
})
if err != nil {
http.Error(w, err.Error(), 500)
return
}
var item map[string]interface{}
attributevalue.UnmarshalMap(out.Item, &item)
json.NewEncoder(w).Encode(item)
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
java/src/main/java/com/zomato/orders/OrderController.java
package com.zomato.orders;
import java.net.URI;
import java.util.Map;
import org.springframework.web.bind.annotation.*;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.DynamoDbClientBuilder;
import software.amazon.awssdk.services.dynamodb.model.*;
@RestController
public class OrderController {
private final DynamoDbClient db;
public OrderController() {
DynamoDbClientBuilder b = DynamoDbClient.builder();
String ep = System.getenv("DDB_ENDPOINT");
if (ep != null) b.endpointOverride(URI.create(ep));
this.db = b.build();
}
@GetMapping("/orders/1001")
public Object one() {
GetItemResponse r = db.getItem(GetItemRequest.builder()
.tableName(System.getenv("TABLE"))
.key(Map.of("pk", AttributeValue.fromS("CUSTOMER#7"),
"sk", AttributeValue.fromS("ORDER#1001")))
.build());
return Map.of("dish", r.item().get("dish").s(), "status", r.item().get("status").s());
}
}
dotnet/Program.cs
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.DocumentModel;
var b = WebApplication.CreateBuilder(args);
var app = b.Build();
var conf = new AmazonDynamoDBConfig();
var ep = Environment.GetEnvironmentVariable("DDB_ENDPOINT");
if (!string.IsNullOrEmpty(ep)) conf.ServiceURL = ep;
else conf.RegionEndpoint = Amazon.RegionEndpoint.APSouth1;
var client = new AmazonDynamoDBClient(conf);
var table = Table.LoadTable(client, Environment.GetEnvironmentVariable("TABLE"));
app.MapGet("/orders/1001", async () =>
{
var doc = await table.GetItemAsync("CUSTOMER#7", "ORDER#1001");
return Results.Content(doc.ToJson(), "application/json");
});
app.Run("http://0.0.0.0:8082");
compose.localstack.yaml
services:
localstack:
image: localstack/localstack:3.8
environment:
SERVICES: dynamodb,s3
ports:
- "4566:4566"
volumes:
- ls-data:/var/lib/localstack
volumes:
ls-data:
What each line does
o.BaseEndpoint— Go SDK v2 override. Empty means real AWS.endpointOverride(URI)— The Java SDK v2 way. The builder still reads region and credentials from the environment.conf.ServiceURL— The .NET way. When we set ServiceURL we also need AWS_REGION in the environment.Table.LoadTable + GetItemAsync— The .NET document model reads hash and range key in one call.SERVICES: dynamodb,s3— LocalStack starts only what we need, so it uses less memory.Port 4566— LocalStack has one edge port for every service. Set DDB_ENDPOINT=http://localstack:4566.
Green output
$ for p in 8080 8081 8082; do curl -s localhost:$p/orders/1001; echo; done
{"dish":"Misal Pav","lang":"seed","pk":"CUSTOMER#7","sk":"ORDER#1001","status":"PLACED"}
{"dish":"Misal Pav","status":"PLACED"}
{"pk":"CUSTOMER#7","sk":"ORDER#1001","dish":"Misal Pav","status":"PLACED","lang":"seed"}
$ docker compose -f compose.localstack.yaml up -d
$ docker run --rm --network dynamo-langs_default -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_REGION=ap-south-1 amazon/aws-cli:2.17.0 dynamodb list-tables --endpoint-url http://localstack:4566
{ "TableNames": [] }
Expected result. Go, Java and .NET read the same item. LocalStack works as a drop-in fake AWS by changing one URL.
Example 4: Real DynamoDB: on-demand table, a least-privilege IAM role on EC2, PHP client, and full cleanup
Goal. Real DynamoDB: on-demand table, a least-privilege IAM role on EC2, PHP client, and full cleanup.
aws/01-table.sh
export AWS_REGION=ap-south-1
aws dynamodb create-table --table-name orders \
--attribute-definitions AttributeName=pk,AttributeType=S AttributeName=sk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--tags Key=project,Value=docker-course
aws dynamodb wait table-exists --table-name orders
aws/02-policy.json
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:Query"],
"Resource": "arn:aws:dynamodb:ap-south-1:111122223333:table/orders" }
]
}
aws/03-role.sh
aws iam create-role --role-name orders-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 orders-host-role --policy-name orders-table \
--policy-document file://aws/02-policy.json
aws iam create-instance-profile --instance-profile-name orders-host-profile
aws iam add-role-to-instance-profile --instance-profile-name orders-host-profile --role-name orders-host-role
aws ec2 associate-iam-instance-profile --instance-id i-0abc1234def567890 \
--iam-instance-profile Name=orders-host-profile
aws ec2 modify-instance-metadata-options --instance-id i-0abc1234def567890 \
--http-tokens required --http-put-response-hop-limit 2
php/index.php
<?php
require 'vendor/autoload.php';
use Aws\DynamoDb\DynamoDbClient;
use Aws\DynamoDb\Marshaler;
$opts = ['region' => getenv('AWS_REGION'), 'version' => 'latest'];
if (getenv('DDB_ENDPOINT')) { $opts['endpoint'] = getenv('DDB_ENDPOINT'); }
$db = new DynamoDbClient($opts);
$m = new Marshaler();
$r = $db->getItem(['TableName' => getenv('TABLE'),
'Key' => $m->marshalItem(['pk' => 'CUSTOMER#7', 'sk' => 'ORDER#1001'])]);
header('Content-Type: application/json');
echo json_encode($m->unmarshalItem($r['Item'] ?? []));
php/Dockerfile
FROM composer:2 AS deps
WORKDIR /app
RUN composer require aws/aws-sdk-php:^3.320 --no-interaction --no-progress
FROM php:8.3-apache
COPY --from=deps /app/vendor /var/www/html/vendor
COPY index.php /var/www/html/index.php
EXPOSE 80
compose.aws.yaml
x-aws: &aws
environment:
TABLE: orders
AWS_REGION: ap-south-1
restart: unless-stopped
services:
node: { <<: *aws, build: ./node, ports: ["3000:3000"] }
python: { <<: *aws, build: ./python, ports: ["8001:8000"] }
go: { <<: *aws, build: ./go, ports: ["8080:8080"] }
java: { <<: *aws, build: ./java, ports: ["8081:8081"] }
dotnet: { <<: *aws, build: ./dotnet, ports: ["8082:8082"] }
php: { <<: *aws, build: ./php, ports: ["8083:80"] }
aws/99-cleanup.sh
docker compose -f compose.aws.yaml down --rmi local
docker compose -f compose.yaml -f compose.local.yaml down -v
aws dynamodb delete-table --table-name orders
aws dynamodb wait table-not-exists --table-name orders
aws ec2 disassociate-iam-instance-profile --association-id iip-assoc-0123456789abcdef0
aws iam remove-role-from-instance-profile --instance-profile-name orders-host-profile --role-name orders-host-role
aws iam delete-instance-profile --instance-profile-name orders-host-profile
aws iam delete-role-policy --role-name orders-host-role --policy-name orders-table
aws iam delete-role --role-name orders-host-role
What each line does
PAY_PER_REQUEST— On-demand. A few thousand practice requests cost almost nothing, and the always-free tier covers 25 GB storage.Resource: table/orders— The role can touch only this table. No Scan, no DeleteTable, no other tables.No keys in compose.aws.yaml— There is no DDB_ENDPOINT and no AWS_ACCESS_KEY_ID. The SDK uses the instance role.hop-limit 2— Containers need one extra hop to reach the metadata service for role credentials.composer build stage— PHP dependencies are installed in a separate stage, so composer is not in the final image.down -v— Also removes the local ddb-data volume.
Green output
$ bash aws/01-table.sh && bash aws/03-role.sh
$ docker compose -f compose.aws.yaml up -d --build
$ curl -s -X POST localhost:3000/customers/7/orders -H 'content-type: application/json' -d '{"id":"1001","dish":"Misal Pav"}'
{"pk":"CUSTOMER#7","sk":"ORDER#1001","dish":"Misal Pav","status":"PLACED","lang":"node"}
$ curl -s localhost:8083
{"pk":"CUSTOMER#7","sk":"ORDER#1001","dish":"Misal Pav","status":"PLACED","lang":"node"}
$ bash aws/99-cleanup.sh
$ aws dynamodb list-tables
{ "TableNames": [] }
Expected result. The same six images run on real DynamoDB with a table-scoped role, then everything is deleted.
Every command, step by step (plain docker and Compose)
-
Start DynamoDB Local
docker compose up -d dynamodb-localWhat it does: Starts the local database on port 8000 with a data volume.
Container dynamo-langs-dynamodb-local-1 Started -
Create the table
docker compose run --rm init-tableWhat it does: Creates orders with pk and sk and one seed item.
table created -
Start Node and Python
docker compose -f compose.yaml -f compose.local.yaml up -d --build node pythonWhat it does: Both use DDB_ENDPOINT to reach the local table.
Container dynamo-langs-python-1 Started -
Add an order
curl -s -X POST localhost:3000/customers/7/orders -H 'content-type: application/json' -d '{"id":"1002","dish":"Vada Pav"}'What it does: Node writes the item with a condition so it cannot overwrite.
{"pk":"CUSTOMER#7","sk":"ORDER#1002" ...} -
Update status
curl -s -X PATCH localhost:8001/customers/7/orders/1002/status/DELIVEREDWhat it does: Python updates one attribute.
"status":"DELIVERED" -
List orders
curl -s localhost:8001/customers/7/ordersWhat it does: One Query returns both orders.
[{... ORDER#1001 ...},{... ORDER#1002 ...}] -
Restart test
docker compose restart dynamodb-local && curl -s localhost:8001/customers/7/ordersWhat it does: Data is still there because of the volume.
[{... ORDER#1001 ...},{... ORDER#1002 ...}] -
Real AWS
bash aws/01-table.sh && bash aws/03-role.sh && docker compose -f compose.aws.yaml up -d --buildWhat it does: Real table, role on EC2, no endpoint, no keys.
Container dynamo-langs-php-1 Started -
Check identity
docker run --rm amazon/aws-cli sts get-caller-identityWhat it does: Shows the assumed role, not a user.
assumed-role/orders-host-role/i-0abc1234def567890 -
Clean up
bash aws/99-cleanup.shWhat it does: Deletes the table, role, profile, containers and the local volume.
{ "TableNames": [] }
When it is red 1
ResourceNotFoundException: Cannot do operations on a non-existent table
Cause. The table was not created on this endpoint, or the app points to a different endpoint or region than the CLI.
Fix, step by step
- Run init-table again.
- Check DDB_ENDPOINT and AWS_REGION in the app match the CLI.
- With DynamoDB Local, use -sharedDb so region and key do not split data.
When it is red 2
UnrecognizedClientException: The security token included in the request is invalid.
Cause. On real AWS the container still has fake keys test/test from the local file.
Fix, step by step
- Remove AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY from the AWS compose file.
- Let the SDK use the instance role.
When it is red 3
AccessDeniedException: ... is not authorized to perform: dynamodb:Scan on resource: .../table/orders
Cause. The code calls Scan, but the role allows only Get, Put, Update and Query.
Fix, step by step
- Use Query with the partition key instead of Scan.
- Only add Scan to the policy if you really need it.
When it is red 4
connect ECONNREFUSED 127.0.0.1:8000
Cause. The app uses localhost inside the container. localhost is the container itself.
Fix, step by step
- Use the service name: http://dynamodb-local:8000.
When it is red 5
ConditionalCheckFailedException: The conditional request failed
Cause. The order id already exists. Our condition protects it.
Fix, step by step
- Use a new order id, or return 409 Conflict to the client.
Practice tasks
Easy Task 1: Local table
Why: Start.
What to do: Start DynamoDB Local and list tables.
- Use Docker Desktop or any Linux box with Docker, and the AWS CLI v2.
- Create the dynamo-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 dynamodb-local
docker compose run --rm init-tableEasy Task 2: First order
Why: Basics.
What to do: Add an order with Node and read it with Python.
- Use Docker Desktop or any Linux box with Docker, and the AWS CLI v2.
- Create the dynamo-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
curl -s -X POST localhost:3000/customers/9/orders -H 'content-type: application/json' -d '{"id":"2001","dish":"Pohe"}'
curl -s localhost:8001/customers/9/ordersEasy Task 3: Persistence
Why: Volumes.
What to do: Restart DynamoDB Local and prove the data stayed.
- Use Docker Desktop or any Linux box with Docker, and the AWS CLI v2.
- Create the dynamo-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker compose restart dynamodb-localMedium Task 4: Duplicate
Why: Conditions.
What to do: Post the same order twice and see the error.
- Use Docker Desktop or any Linux box with Docker, and the AWS CLI v2.
- Create the dynamo-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
Second POST returns ConditionalCheckFailedExceptionMedium Task 5: LocalStack
Why: Fake AWS.
What to do: Point Python at LocalStack and create the table there.
- Use Docker Desktop or any Linux box with Docker, and the AWS CLI v2.
- Create the dynamo-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
DDB_ENDPOINT=http://localstack:4566Medium Task 6: All six
Why: Matrix.
What to do: Run all six languages and compare output.
- Use Docker Desktop or any Linux box with Docker, and the AWS CLI v2.
- Create the dynamo-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
for p in 3000 8001 8080 8081 8082 8083; do curl -s localhost:$p/orders/1001; echo; doneMedium Task 7: Least privilege
Why: IAM.
What to do: Call Scan and read the AccessDenied message.
- Use Docker Desktop or any Linux box with Docker, and the AWS CLI v2.
- Create the dynamo-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
aws dynamodb scan --table-name ordersHard Task 8: GSI
Why: Indexes.
What to do: Add a status-index GSI and query PLACED orders.
- Use Docker Desktop or any Linux box with Docker, and the AWS CLI v2.
- Create the dynamo-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
aws dynamodb update-table --table-name orders --attribute-definitions AttributeName=status,AttributeType=S --global-secondary-index-updates '[{"Create":{"IndexName":"status-index","KeySchema":[{"AttributeName":"status","KeyType":"HASH"}],"Projection":{"ProjectionType":"ALL"}}}]'Hard Task 9: TTL
Why: Expiry.
What to do: Turn on TTL with an expires_at attribute.
- Use Docker Desktop or any Linux box with Docker, and the AWS CLI v2.
- Create the dynamo-langs folder with the files from this lesson.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
aws dynamodb update-time-to-live --table-name orders --time-to-live-specification Enabled=true,AttributeName=expires_atEasy Task 10: Clean
Why: Habit.
What to do: Delete the table, role and volume.
- Use Docker Desktop or any Linux box with Docker, and the AWS CLI v2.
- Create the dynamo-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.shHomework: try at home
Add a Global Secondary Index on status so the kitchen can list all PLACED orders, test it on DynamoDB Local, then add a TTL attribute that deletes delivered orders after 30 days.
Recap
- DynamoDB is a serverless key-value database; design keys around your queries.
- Practise with amazon/dynamodb-local or LocalStack in Compose first.
- One env var, DDB_ENDPOINT, switches between local and real AWS.
- Use fake keys only locally; on EC2 use an instance role and hop limit 2.
- Give the role only the actions and the one table it needs.
- Delete the table, role and local volume after practice.
FAQ
Q. Is DynamoDB free?
The always-free tier includes 25 GB of storage. On-demand practice requests cost a few paise.
Q. DynamoDB Local or LocalStack?
DynamoDB Local is official and light. LocalStack fakes many services, useful when you also need S3 or SQS.
Q. Does DynamoDB Local behave exactly like AWS?
Almost. Capacity limits, IAM and some newer features are not enforced.
Q. Can I use access keys on my laptop for real AWS?
Use aws sso login or a named profile mounted read-only. Never copy keys into images or compose files.
Q. Why no connection pool here?
DynamoDB is HTTPS per request. The SDK reuses HTTP connections for you.
Q. Can I use DynamoDB from ECS or EKS?
Yes. Use an ECS task role or EKS Pod Identity instead of the instance role.
Interview questions
-
How do you test DynamoDB code in CI without AWS?
Model answer: Run amazon/dynamodb-local or LocalStack as a service container and set the endpoint override.
-
How do you give a container least-privilege DynamoDB access?
Model answer: A role with only the needed actions on the exact table ARN, delivered by instance role, task role or pod identity.
-
Query vs Scan?
Model answer: Query reads one partition by key and is cheap. Scan reads everything and gets slow and costly.
-
How do you prevent overwrites in DynamoDB?
Model answer: Use a ConditionExpression such as attribute_not_exists on the key.
-
When would you pick DynamoDB over RDS?
Model answer: Key-based access at large scale with no joins, and no servers to manage.
Quick quiz
Q1. Why test on DynamoDB Local first?
It is free, works offline, and the same code runs on AWS by removing one env var.
Q2. What changes between local and AWS?
Only DDB_ENDPOINT and the fake keys. Both are removed for AWS.
Q3. Why pk and sk with prefixes?
One table can hold many types, and one Query gets all orders of a customer.
Q4. How does the container get AWS access?
Through the EC2 instance role and the IMDSv2 hop limit 2.
Q5. Why not Scan?
Scan reads the whole table and costs more. Query reads only one partition.
Samajla ka?
Revision card
Interview card