Filter concepts by levelShowing all levels.

AWS · Section 68

Practical AWS Projects

Level
advanced
Read
55 min
Concepts
5

Five projects that between them exercise everything earlier sections teach, ordered so each one adds a single new difficulty. The first is the containerised backend most engineers are actually asked for, and its hard part is not the components but the release: two roles rather than one, migrations as a gated task rather than a container entrypoint, and secrets referenced by ARN so they never enter an image or a repository. The second is the same API with no servers, where the design work moves to the joins — which calls must fit inside the twenty-nine second ceiling, which become events nobody waits for, and what happens when a consumer runs twice — and where the table is designed from a written list of queries rather than from the entities. The third strips the asynchronous half down to a single pipeline and spends all its attention on the two failure paths: a file that can never be processed, and a file that takes longer than the visibility timeout. The fourth reuses the first project's components and adds the one thing that cannot be retrofitted cheaply, tenant identity, which has to appear in the request context, the authorization check, the primary key, the cache key, the object prefix, the queue and every log line and metric. The fifth assembles all of it from code and then proves it, because a platform that works and has never been tested has had its recovery paths executed exactly zero times: lose a zone, restore the database on the clock, and rebuild the whole environment in an empty account from the repository alone. Each project is built outside-in, so every milestone ends in something demonstrable rather than something configured.

What is true here

  1. Build outside-in: reachable on day one, then add the database, secrets and workers.
  2. Two IAM roles per task, and migrations as their own gated release step.
  3. Serverless design lives at the joins: the timeout, the event, and the duplicate.
  4. Tenant identity in seven places from the first table, or a migration later.
  5. A platform is finished when three drills have produced three measured numbers.

What you will be able to do

  • Sequence a real AWS build so each milestone is independently demonstrable
  • Wire a containerised backend with separate roles, gated migrations and injected secrets
  • Design a serverless API from its access patterns and its asynchronous boundaries
  • Thread tenant identity through every layer of a shared platform
  • Prove a high-availability claim with a zone drill, a timed restore and a rebuild
Five projects, five different hard parts
same API,no serversthe async half,on its ownsame components,isolation addedall of it, fromcode, and drilled

1 · Django API

the hard part: release order and two roles

2 · Serverless API

the hard part: access patterns and the 29 s ceiling

3 · File processing

the hard part: the two failure paths

4 · Multi-tenant SaaS

the hard part: tenant identity, in seven places

5 · HA platform

the hard part: proving it with three drills

  • 1 · Django API — the hard part: release order and two roles
    • leads to 2 · Serverless API (same API, no servers)
  • 2 · Serverless API — the hard part: access patterns and the 29 s ceiling
    • leads to 3 · File processing (the async half, on its own)
  • 3 · File processing — the hard part: the two failure paths
    • leads to 4 · Multi-tenant SaaS (same components, isolation added)
  • 4 · Multi-tenant SaaS — the hard part: tenant identity, in seven places
    • leads to 5 · HA platform (all of it, from code, and drilled)
  • 5 · HA platform — the hard part: proving it with three drills

Practical AWS Projects

Five builds, in order of difficulty, each with its milestones, the decision that is cheap now and expensive later, and the drill or test that proves the result.

Project 1 — A Production Django API

coreintermediate

The first project is the one most backend engineers are actually asked to build: a containerised Django API behind a load balancer, with a managed database, a cache, object storage and no secrets in the image. Build it in seven milestones, and finish each one with something you can demonstrate rather than something you have configured.

Think of it as

Build outside-in and keep it reachable at every step. A hello-world container behind a load balancer on day one gives you a working deployment pipeline to add to; a perfect task definition with nothing serving traffic gives you a debugging session. Every milestone here ends with a request you can make from a browser.

What we're doing: Sequence the build so every milestone leaves something working.

build-order.shbash
# Milestone 1 first, and deliberately trivial. The point is a
# working path from the internet to a private subnet.

# VPC: 2 public + 2 private app + 2 private data subnets, 2 AZs.
# ALB in the public subnets, ECS service in the private app ones.
# The container is nginx. It returns the default page. That is
# the milestone — the deployment path exists and is reachable.

curl -sS https://api.example.com/ | head -1
# <!DOCTYPE html>   ← the whole first day's deliverable

# Milestone 3: the database. Note the security group rule — the
# source is a GROUP, never a CIDR.
aws ec2 authorize-security-group-ingress \
  --group-id sg-db01 \
  --protocol tcp --port 5432 \
  --source-group sg-app01

# Milestone 5: migrations as their own task, gated on exit code.
TASK=$(aws ecs run-task --cluster prod \
  --task-definition orders-migrate:12 \
  --launch-type FARGATE --network-configuration "$NETCFG" \
  --query 'tasks[0].taskArn' --output text)

aws ecs wait tasks-stopped --cluster prod --tasks "$TASK"

CODE=$(aws ecs describe-tasks --cluster prod --tasks "$TASK" \
  --query 'tasks[0].containers[0].exitCode' --output text)

[ "$CODE" = "0" ] || { echo "migration failed"; exit 1; }

aws ecs update-service --cluster prod --service orders-web \
  --task-definition orders-web:47
3
Getting the network and the deployment path working with a trivial container is the single highest-value first day, because everything later is debugged through it.
12
`--source-group` rather than a CIDR is what stops a future workload in the same address range inheriting database access.
20
Gating the service update on the migration exit code turns "we ran migrations" into a release step that can fail safely.

Why this works: Building outside-in means every milestone is verifiable from a browser or a single command, so a mistake is caught while only one thing has changed. The alternative — assembling the whole task definition, database, cache and secrets before the first request — produces a failure with six plausible causes and no way to bisect them.

Putting the database password in the environment block

Wrong

json
"environment": [
  { "name": "DATABASE_URL",
    "value": "postgres://app:s3cr3t@orders.rds.amazonaws.com/app" }
]

Better

json
"secrets": [
  { "name": "DATABASE_URL",
    "valueFrom": "arn:aws:secretsmanager:eu-west-1:111122223333:secret:orders/db-AbC123" }
]

What you see: The password is visible to anyone with `ecs:DescribeTaskDefinition`, is committed to the repository holding the template, and appears in CloudFormation change sets and CI logs — with no way to rotate it that does not require a deployment.

Why: The `environment` block is part of the task definition, which is a readable API object and usually also a file in version control. The `secrets` block stores only a reference: the value is fetched by the execution role at start and never becomes part of the definition, so rotating it is a Secrets Manager operation rather than a release.

Project 1, end to end

Traffic flows downward through the left column. The right column is what the tasks reach out to, and the bottom band is what runs beside the release rather than during a request.

  • An architecture diagram of a Django API on AWS, read top to bottom.
  • A browser resolves the domain through Route 53 and reaches an Application Load Balancer in the public subnets across two Availability Zones.
  • The load balancer forwards to ECS Fargate web tasks in private subnets, running gunicorn, spread across the same two zones.
  • The web tasks read and write RDS PostgreSQL with a Multi-AZ standby, use ElastiCache Redis for caching and sessions, and put user uploads into an S3 bucket.
  • A separate set of ECS worker tasks runs Celery, consumes from the same Redis or an SQS queue, and has no load balancer in front of it.
  • Secrets Manager supplies the database password and API keys to both task sets at start time, by ARN.
  • CloudWatch collects logs, metrics and alarms from every component.
  • A one-off migration task runs before the new service revision rolls out; static assets are served from S3 through CloudFront.

Seven milestones, each ending in something you can demonstrate

Seven milestones, each ending in something you can demonstrate
#MilestoneDone whenWhat it teaches
1Network and a hello-world containerA public URL returns 200 from a container in a private subnetVPC layout, ALB target groups, ECS service basics
2The real image, from ECRThe Django app serves a page, still with no databaseImage build, ECR authentication, task definition shape
3RDS PostgreSQL, Multi-AZA view reads a table; the security group allows only the app groupSubnet groups, security-group-to-security-group rules
4Secrets ManagerNo credential exists in the image, the repo or the task definition bodyExecution role permissions, secret ARN injection
5Migrations as a release stepA failed migration stops the release before any task rollsOne-off tasks, ordering, deployment gates
6Redis, S3 media, static via CloudFrontAn upload lands in S3 and is served back; a cached view is measurably fasterCache-aside, presigned uploads, origin access control
7Workers, alarms and a runbookA queued job runs; an alarm fires and points at a written actionSeparate scaling, deep health checks, operability

Together

json
// The part of the task definition that carries the two roles and
// the injected secret — the piece most first attempts get wrong.
{
  "family": "orders-web",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],

  // Used by the ECS agent: pull the image, fetch the secret,
  // write the logs. Not available to your code.
  "executionRoleArn": "arn:aws:iam::111122223333:role/orders-exec",

  // Used by your code: S3, SQS, anything the app calls.
  "taskRoleArn": "arn:aws:iam::111122223333:role/orders-task",

  "containerDefinitions": [{
    "name": "web",
    "image": "111122223333.dkr.ecr.eu-west-1.amazonaws.com/orders:sha-9f2c1d",
    "command": ["gunicorn", "config.wsgi", "--bind", "0.0.0.0:8000"],
    "portMappings": [{ "containerPort": 8000 }],

    // The value never appears here. Only the ARN does.
    "secrets": [
      { "name": "DATABASE_URL",
        "valueFrom": "arn:aws:secretsmanager:eu-west-1:111122223333:secret:orders/db-AbC123" }
    ],

    "environment": [{ "name": "DJANGO_SETTINGS_MODULE", "value": "config.settings.production" }],

    "healthCheck": {
      "command": ["CMD-SHELL", "curl -f http://localhost:8000/internal/health || exit 1"],
      "interval": 15, "timeout": 5, "retries": 2, "startPeriod": 30
    },

    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "/ecs/orders-web",
        "awslogs-region": "eu-west-1",
        "awslogs-stream-prefix": "web"
      }
    }
  }]
}

Remember: Build outside-in: a reachable hello-world before anything else, then the database, then secrets by ARN, then migrations as their own gated task, then cache and storage, then workers and alarms. Two roles, two services, two scaling signals — and a health check that touches the database.

See also: mapping a backend to aws · separate scaling for web and workers · deploying a web service end to end · three tier web application · project 2 serverless api

Project 2 — A Serverless API

coreintermediate

The second project is the same kind of API with none of the servers. API Gateway takes the request, a Lambda function handles it, DynamoDB stores the result, and anything slow is pushed onto EventBridge or SQS so the response can return immediately. The design work moves from capacity planning to access patterns and limits.

Think of it as

A serverless API is a set of small pieces joined by events, so the interesting decisions are at the joins. Which calls are synchronous and must fit inside the API Gateway timeout, which are events nobody waits for, and what happens when a piece is invoked twice. Get those three right and the compute layer barely matters.

What we're doing: Design the DynamoDB table from the access patterns rather than from the entities.

table-design.txttext
Step 1 — write the queries first. Every one the API performs.

  Q1  get one order by id                       (most frequent)
  Q2  list a customer's orders, newest first
  Q3  list orders in a status, for the ops view
  Q4  get the line items of an order
  Q5  monthly totals per customer               (reporting)

Step 2 — map each to a key or an index. Not to a table.

  pk = ORDER#<order_id>      sk = META           → Q1
  pk = ORDER#<order_id>      sk = ITEM#<n>       → Q4
                             (Q1 and Q4 share a partition, so
                              one Query returns the whole order)

  GSI1  pk = CUST#<customer_id>  sk = <created_at>   → Q2
  GSI2  pk = STATUS#<status>     sk = <created_at>   → Q3

Step 3 — be explicit about what does not fit.

  Q5 is an aggregation over a date range across all customers.
  DynamoDB has no efficient answer to that shape. Options:
    - maintain a running total with a counter update per order
    - export to S3 and query with Athena
  Choosing is fine. Discovering it after launch is not, and this
  is the step that finds it.

Step 4 — sanity check the keys.
  Is any partition key low-cardinality? STATUS#<status> is: most
  orders sit in two or three statuses, so GSI2 has hot partitions
  by construction. Add a suffix — STATUS#<status>#<shard 0-9> —
  and fan the query across the ten shards, or accept it if the
  ops view is low-traffic. Both are defensible; not noticing is
  not.
1
Writing the queries before the table is the whole method, and it is the step that makes DynamoDB predictable rather than surprising.
9
Sharing a partition between the order and its items is what turns two round trips into one Query.
20
Naming the pattern that does not fit — and choosing a way to handle it — is more valuable than a model where everything appears to fit.

Why this works: DynamoDB gives predictable latency at any size in exchange for knowing the access patterns up front, and this is where that trade is actually paid. The design either enumerates the queries and finds the awkward one before launch, or it does not and finds it during a reporting request six months later, when the table is large and the fix is a migration.

Letting an SQS-triggered Lambda scale into a fragile dependency

Wrong

text
# Queue backs up to 40,000 messages after an outage.
# The consumer Lambda scales out and opens 900 connections to
# an RDS instance whose max_connections is 200.

Better

text
# Reserved concurrency on the consumer, sized to what the
# database can take:
#   reserved concurrency = 20
# The backlog drains more slowly and nothing else falls over.

What you see: Recovery from a minor outage causes a larger one: the queue drains at full speed, the database refuses connections, and the errors spread to the synchronous API that shares it.

Why: Scaling with the queue is the behaviour you want for throughput and the behaviour you must bound whenever the consumer touches something that does not scale the same way. Reserved concurrency is the cap that converts an unbounded surge into a steady drain — the same reasoning as a bounded worker pool, expressed as a Lambda setting.

Project 2, and where the synchronous path ends

Everything above the dashed line happens while the caller waits and must fit inside 29 seconds. Everything below it happens afterwards, at least once, and needs a dead-letter queue.

  • A serverless API diagram split into a synchronous half and an asynchronous half.
  • Above the line: a client calls API Gateway, which invokes a Lambda function, which reads and writes DynamoDB and returns. This path must complete within the 29-second REST API integration timeout.
  • Large uploads bypass the API: the client requests a presigned URL and uploads directly to S3.
  • Below the line: the handler publishes an event to EventBridge. Rules route it to SQS queues, each with its own consumer Lambda and its own dead-letter queue.
  • A DynamoDB stream and an S3 object-created event are two more sources that feed the same asynchronous side.
  • Footnote: each asynchronous consumer must be idempotent, because delivery is at least once and Lambda retries on its own.

Six milestones, and the limit each one makes you meet

Six milestones, and the limit each one makes you meet
#MilestoneDone whenThe limit it teaches
1Access patterns, then the tableEvery query the API needs is listed and maps to a key or an indexA pattern you did not plan may need a new GSI
2API Gateway → Lambda → DynamoDBCreate and read work end to end with real dataThe 29-second REST integration timeout
3Presigned uploads to S3A file reaches S3 without passing through the APIPayload size, and function duration on large bodies
4Publish an event and consume itA slow step happens after the response, on a queueAt-least-once delivery, so the consumer is idempotent
5Failure pathsA permanently failing event lands in a dead-letter queue with its causeAsync retries end silently without a destination
6Limits and alarmsConcurrency is capped where a downstream is fragile, and alarms existConcurrency, throttling, and queue depth as a signal

Together

python
# The handler: do the fast part, publish the rest, return.

import json
import os
import boto3

ddb = boto3.resource("dynamodb").Table(os.environ["ORDERS_TABLE"])
events = boto3.client("events")

def handler(event, context):
    body = json.loads(event["body"])
    order_id = body["order_id"]

    # Fast, synchronous, inside the timeout: one write.
    ddb.put_item(
        Item={"pk": f"ORDER#{order_id}", "sk": "META", "status": "received", **body},
        ConditionExpression="attribute_not_exists(pk)",   # idempotent create
    )

    # Slow work does not belong here. Publish a fact and stop.
    events.put_events(Entries=[{
        "EventBusName": os.environ["BUS_NAME"],
        "Source": "orders.api",
        "DetailType": "OrderReceived",
        "Detail": json.dumps({"order_id": order_id}),
    }])

    return {"statusCode": 202,
            "body": json.dumps({"order_id": order_id, "status": "received"})}

# 202, not 200. The order is accepted and not yet complete, and
# saying so in the status code is what lets the email, the search
# index update and the billing call all happen after the response
# without the client waiting for any of them.

Remember: Write the access patterns before the table. Keep the synchronous path inside 29 seconds and push everything else onto an event. Every asynchronous consumer is idempotent, capped where its dependency is fragile, and has a dead-letter queue with an alarm on it.

See also: serverless api · single table design and modeling · serverless operational limits · retries and idempotency · project 3 file processing system

Project 3 — A File-Processing System

standardintermediate

A file lands in S3, an event goes onto a queue, a worker picks it up, writes the result to another prefix and notifies whoever asked. The pipeline is short, and almost all the difficulty is in the two questions it forces: what happens when the same file is processed twice, and what happens when one file can never be processed at all.

Think of it as

Treat the object key as the unit of work and the queue as the only source of truth about what still needs doing. The worker should be able to crash at any moment, restart, receive the same message again, and produce the same result — which means the output location is derived from the input, never appended to.

text
presigned upload → S3 event → SQS → worker → derived output key → notify · DLQ on the queue, alarm on its depth
One file, one message, one derived output

The two failure paths are drawn as prominently as the success path, because they are where the design work is. Everything else is a straight line.

  • A file-processing pipeline drawn left to right, with failure paths shown below.
  • A client requests a presigned URL and uploads a file directly to the S3 input prefix.
  • S3 emits an ObjectCreated event, which is delivered to an SQS queue rather than straight to the worker.
  • A worker — a Lambda for short jobs, an ECS task for long or memory-heavy ones — receives the message, reads the input object, processes it, and writes the result to the output prefix using a key derived from the input key.
  • On success the worker deletes the message and publishes a completion notification through SNS or EventBridge.
  • Failure path one: the message becomes visible again after the visibility timeout and is retried; after the maximum receive count it moves to a dead-letter queue with an alarm on its depth.
  • Failure path two: the visibility timeout is shorter than the processing time, so the message is redelivered while the first attempt is still running and two workers process the same file.

Generating a fresh output key on every run

Wrong

python
out_key = f"output/{uuid4()}.json"
# A retry produces a second output file for the same input, and
# nothing downstream can tell which one is current.

Better

python
out_key = f"output/{stem(input_key)}.processed.json"
# A retry overwrites. Reprocessing the whole bucket is safe.
# "Has this been done?" becomes a HeadObject call.

What you see: The output prefix slowly fills with near-duplicate files, downstream consumers pick an arbitrary one, and rerunning the pipeline over historical data doubles the storage instead of refreshing it.

Why: A derived key makes the operation idempotent for free, with no deduplication table and no extra state. It also makes two other things cheap: checking whether an input has been processed becomes a single HeadObject, and reprocessing a batch after a bug fix becomes safe to run repeatedly.

Five milestones, and the decision each one forces

Five milestones, and the decision each one forces
#MilestoneDone whenThe decision it forces
1Presigned upload into an input prefixA file reaches S3 without touching your applicationWhich bucket and prefix, and who may write there
2Event into a queue, not into a workerAn upload produces exactly one queued messageThe queue is the buffer; the event alone has no retry story
3A worker that writes a derived output keyReprocessing the same file overwrites and does not duplicateLambda or ECS — decided by duration and memory, not preference
4Visibility timeout sized to the workThe slowest realistic file completes before redeliveryWhat the slowest file actually is, measured rather than guessed
5Dead-letter queue and notificationA poison message is visible; a finished file notifies the requesterWhat "failed" means to the user, not only to the worker

Together

python
# The worker. Two details carry the whole idempotency story.

import os
import boto3

s3 = boto3.client("s3")
OUT_BUCKET = os.environ["OUTPUT_BUCKET"]

def output_key(input_key: str) -> str:
    # DERIVED, not generated. Same input → same output key, so a
    # second run overwrites the first instead of adding a file.
    # A uuid here would create a duplicate on every retry.
    name = input_key.removeprefix("input/").rsplit(".", 1)[0]
    return f"output/{name}.processed.json"

def handle(record: dict) -> None:
    bucket = record["s3"]["bucket"]["name"]
    key = record["s3"]["object"]["key"]

    body = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
    result = process(body)

    s3.put_object(Bucket=OUT_BUCKET, Key=output_key(key), Body=result)
    # The message is deleted only after this returns. A crash
    # before it means the file is processed again — and because
    # the key is derived, that is a no-op rather than a duplicate.

# Sizing the visibility timeout, with the real numbers:
#   p99 processing time      : 90 s
#   safety factor            : ×3
#   visibility timeout       : 300 s
#   Lambda function timeout  : 240 s   (must be under the above)
#   maxReceiveCount          : 3, then the DLQ

Remember: S3 event into a queue, never straight into a worker. Derive the output key from the input key so a retry overwrites rather than duplicates. Size the visibility timeout from the slowest real file, keep the function timeout under it, and put an alarm on the dead-letter queue depth.

See also: file processing pipeline · visibility timeout sizing and extension · sqs dlq redrive and idempotent consumers · multipart upload and large files · project 4 multi tenant saas

Project 4 — A Multi-Tenant SaaS Platform

coreadvanced

This project has the same components as the first one and a completely different hard part. Tenant identity has to be present in every query, every cache key, every object prefix, every log line and every metric — from the first table, not from the first incident. Retrofitting it is a data migration plus an audit of every access path in the codebase.

Think of it as

Assume the tenant id will be missing somewhere and design so that missing means denied rather than means everyone. A query with no tenant filter should fail, not return the whole table; a cache key with no tenant should miss, not hit another customer's entry. Every mechanism here exists to make the absent case safe.

What we're doing: See what retrofitting tenant isolation actually costs, so it gets designed in.

retrofit.txttext
The system launched with one big customer. Tenant id lives on
the users table and nowhere else. Now there are forty customers.

What it takes to add tenant isolation properly, in order:

1. SCHEMA — add tenant_id to 34 tables.
   Nullable first, backfill from the user relationship, then
   NOT NULL. Two of the tables have no path back to a user at
   all: audit_log rows written by a system job, and the
   attachments table keyed only by a file hash. Those two need
   a decision from someone who knows the data, and the answer
   for the attachments table is "we cannot tell" for 11,000
   rows written before a column was dropped last year.

2. QUERIES — audit every access path. 612 query sites.
   A grep finds the obvious ones. It does not find the ORM
   relationship traversals, which are the dangerous ones.

3. CACHE — every key changes shape, so the cache is cold on
   deploy and the database takes the full load. This has to be
   a warm-up, not a cutover.

4. S3 — 4.2 TB of objects under a flat prefix. Copying them
   into tenant prefixes is a batch job, a dual-read window and
   an IAM policy change.

5. LOGS AND METRICS — a year of history has no tenant field, so
   "was this customer affected last March?" stays unanswerable
   permanently.

Estimated: one engineer, most of a quarter, with a data question
nobody can answer. Doing all five on day one: about a day.
1
The single-customer start is how almost every multi-tenant system begins, and it is why the shortcut feels reasonable at the time.
6
The rows with no path back to a tenant are the part that cannot be engineered around — the information was never recorded.
17
ORM relationship traversals are why an audit cannot be done with a search: the tenant filter is implied by a join that is no longer there.

Why this works: The cost of retrofitting is not proportional to the work of adding a column; it is dominated by the data that was never captured and by access paths a search will not find. This is the clearest case in the whole roadmap of a decision that is nearly free before launch and expensive forever after, which is why it belongs in the first table rather than the first incident review.

Relying on the application to filter by tenant

Wrong

python
# Every query is expected to include the filter, by convention.
orders = Order.objects.filter(tenant_id=ctx.tenant_id, id=order_id)
# …and one place in the codebase does not.
order = Order.objects.get(id=order_id)   # cross-tenant read

Better

python
# Convention, plus a backstop the convention cannot bypass:
#   - row-level security in PostgreSQL
#   - a base manager that refuses an unscoped query
#   - a test that asserts tenant B cannot read tenant A's ids
# Three layers, because the first one is written by humans.

What you see: A customer reports seeing another customer's data. The query is found in minutes and the damage assessment takes weeks, because nothing in the logs distinguishes a legitimate read from a leaked one.

Why: A convention holds until someone new writes a query, or an existing query is refactored, or a debugging endpoint is added and forgotten. Row-level security fails closed at the layer no code path can skip, and the disclosure test turns the guarantee into something continuous integration checks on every commit rather than something everyone remembers.

Where the tenant id has to appear

The tenant is resolved once, at the top, and then appears at every marked point. Any point where it can be absent is where the isolation fails.

  • A request path down the left with the seven places tenant identity must appear marked along it.
  • A request arrives through CloudFront and an Application Load Balancer to ECS tasks.
  • Point 1, authentication: the tenant is resolved once from the token and put into a request context.
  • Point 2, authorization: every handler checks that the requested resource belongs to the resolved tenant.
  • Point 3, the database: the tenant id is part of every query, enforced by PostgreSQL row-level security so a forgotten filter returns nothing.
  • Point 4, the cache: every Redis key is namespaced with the tenant, so a collision cannot cross customers.
  • Point 5, object storage: each tenant has its own S3 prefix, and access is granted by a policy condition on that prefix.
  • Point 6, queues: work carries the tenant, and a noisy tenant is rate limited or given its own queue.
  • Point 7, observability and cost: logs, metrics and cost allocation all carry the tenant as a dimension.
  • Footnote: retrofitting any of these is a data migration plus an audit of every access path.

The seven places tenant identity appears, and what goes wrong without it

The seven places tenant identity appears, and what goes wrong without it
WhereWhat it looks likeWithout it
Request contextThe tenant is resolved once from the token and attached to the requestEach layer re-reads a header, and one of them trusts a client-supplied value
AuthorizationEvery handler asserts the resource belongs to the resolved tenantAn id in a URL is enough to read another customer's record
DatabaseTenant id in the primary key, plus row-level security as the backstopOne query without a `WHERE tenant_id` returns the whole table
CacheKeys namespaced: `t:<tenant>:orders:42`Two tenants share a key and one sees the other's data intermittently
Object storageA prefix per tenant, with the IAM condition on the prefixA path built in application code, and one bug crosses the boundary
Queues and limitsThe tenant travels with the work; per-tenant rate limits, or its own queueOne customer's bulk import is an outage for everyone else
Observability and costTenant as a log field, a metric dimension and a cost allocation tag"Which customer is affected?" and "which is unprofitable?" cannot be answered

Together

sql
-- Row-level security, which is the backstop that makes a
-- forgotten filter safe rather than catastrophic.

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;   -- applies to the owner too

CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- The application sets it once per request, on the connection
-- it has checked out, before any query runs:
--   SET LOCAL app.tenant_id = '3f0c…';

-- Now the difference that matters:
--   SELECT * FROM orders WHERE id = 42;
-- returns the row only if it belongs to the current tenant, and
-- returns zero rows otherwise. The developer who forgot the
-- tenant filter has written a bug, not a breach.

-- Two things this does not do, and both need their own answer:
--   1. It does nothing if the pooler hands the connection to
--      another request without resetting the setting. SET LOCAL
--      inside a transaction is what makes this safe.
--   2. It does not cover the cache, S3, or the search index.
--      Those need their own tenant scoping.

Remember: Tenant identity goes in from the first table: request context, authorization, primary key with row-level security behind it, namespaced cache keys, per-tenant S3 prefixes, per-tenant limits, and tenant as a log field, metric dimension and cost tag. Design so a missing tenant id denies rather than matching everyone.

See also: the system shapes worth knowing · three tier web application · what tags are used for · cache aside write through and ttl · project 5 highly available platform

Project 5 — A Highly Available Platform, Built From Code

coreadvanced

The last project is the first four assembled properly and, crucially, defined entirely in code. Nothing is created by hand. The finishing test is not that it works — it is that you can destroy an Availability Zone in a drill, restore the database from a backup, and rebuild the whole environment from the repository, each with a measured time.

Think of it as

Treat the repository as the environment and the running account as a cache of it. Anything created in the console is drift that will be lost on the next apply, and anything that cannot be rebuilt from the repository is a single point of failure you have not counted. The three drills — zone loss, restore, full rebuild — are what prove the claim rather than decorate it.

What we're doing: Write a disaster-recovery runbook someone else can execute.

dr-runbook.mdtext
# DR runbook — orders platform
# Agreed with: product, 2026-07-02.  RPO 15 min.  RTO 2 h.
# Last drilled: 2026-08-14.  Result: 1 h 47 m.  Owner: team-orders.

## 0. Declare
   Who declares:  the on-call incident lead, no approval needed.
   Say it out loud in #incident-orders. Start a timer.

## 1. Confirm the primary Region is the problem
   - AWS Health Dashboard for eu-west-1
   - Can you reach the ALB directly, bypassing CloudFront?
   - Is the database endpoint resolving and accepting connections?
   If two of the three are healthy, this is not a Region event.
   Stop here and work the normal incident path.

## 2. Promote the data
   aws rds failover-global-cluster \
     --global-cluster-identifier orders-global \
     --target-db-cluster-identifier orders-eu-central-1
   Expected: 1-2 minutes. Verify with a write, not a read.

## 3. Bring up compute in eu-central-1
   The stack is already deployed at zero desired count.
   aws ecs update-service --cluster dr --service orders-web \
     --desired-count 6
   Wait for 6 healthy targets. Expected: 4-6 minutes.

## 4. Move traffic
   Route 53 failover record is health-check driven and should
   already have switched. If it has not, set it manually:
   aws route53 change-resource-record-sets --hosted-zone-id Z123 \
     --change-batch file://dr-failover.json
   DNS TTL is 60 s, so allow 2 minutes for clients to follow.

## 5. Verify, then tell people
   - a real order, end to end, from an external network
   - the four dashboard signals back to baseline
   - status page updated; support told what customers will see

## 6. Failback — do NOT rush this
   Failback is a separate planned change with its own window.
   Running it under incident pressure is how the second outage
   happens. Written up in dr-failback.md.

## What this runbook assumes
   - the eu-central-1 stack is deployed and current (pipeline
     deploys both Regions on every release — verify in step 1)
   - S3 buckets replicate; objects written in the last 15 minutes
     may be missing, which is what the RPO means in practice
1
The header carries the four facts that make a runbook trustworthy: who agreed the numbers, what they are, when it was last drilled, and what the drill measured.
7
A confirmation step stops the most expensive mistake in DR — failing over for a problem that is not a Region event.
18
Real commands with expected durations, so the person running it knows whether four minutes of waiting is normal or is the next problem.
27
Naming the DNS TTL sets the expectation that traffic moves gradually, which otherwise reads as the failover not having worked.

Why this works: A runbook is judged by whether someone who did not write it can execute it under pressure, and the parts that make that possible are the unglamorous ones: who decides, what confirms the diagnosis, what each step should take, and what the document assumes. The drill result in the header is what turns the RTO from a target into a measurement.

Deploying the DR Region only when it is needed

Wrong

text
# "The Terraform is Region-agnostic, so we can stand up
#  eu-central-1 whenever we need it."

Better

text
# The pipeline deploys both Regions on every release. The DR
# Region runs at desired-count 0, which costs almost nothing
# and means recovery is a scale-up rather than a first apply.

What you see: The recovery becomes a first-time deployment during an outage: a missing service quota in the new Region, an unavailable instance type, a certificate that takes a validation cycle, and an RTO measured in hours instead of minutes.

Why: Code that has never been applied in a Region is untested in that Region, and Regions differ in quotas, instance availability and service coverage. Keeping the stack deployed and scaled to zero converts the risky part — creation — into something the pipeline exercises on every release, leaving only the scale-up for the incident.

The full platform, and the three drills that prove it

The left is the architecture; the right is what makes it a claim you can defend. Without the three drills the diagram is an intention.

  • A complete platform architecture on the left with three verification drills on the right.
  • Edge: Route 53, CloudFront and WAF in front of everything, with static assets served from S3.
  • Public subnets across two Availability Zones: the Application Load Balancer and a NAT gateway.
  • Private application subnets across two zones: ECS or EC2 capacity in an Auto Scaling group, sized so one zone can carry peak load.
  • Private data subnets across two zones: RDS or Aurora with a standby, and ElastiCache with a replica.
  • Gateway endpoints carry S3 and DynamoDB traffic without using the NAT path.
  • A separate log account receives CloudTrail, VPC flow logs, load balancer access logs and application logs.
  • The whole environment is defined in infrastructure as code and deployed by a pipeline from the repository.
  • Drill 1, zone loss: remove one zone from service and measure recovery.
  • Drill 2, restore: restore the database to a point in time and measure how long it took.
  • Drill 3, rebuild: create the entire environment in an empty account from the repository alone.

Eight milestones, and what each one has to prove

Eight milestones, and what each one has to prove
#MilestoneProved byWhat it usually finds
1Network from codeA `terraform apply` or a stack deploy creates every subnet and routeA hand-made route or peering nobody put in the repository
2Multi-AZ compute with real capacityPeak load served with one zone removedAn Auto Scaling maximum sized for two zones, not one
3Private data tier and endpointsNo 0.0.0.0/0 route in the data subnets; S3 traffic off the NAT pathS3 and DynamoDB traffic quietly billed as NAT data processing
4Managed data with a standbyA forced failover completes with the application runningA connection pool that holds dead sockets after the endpoint moves
5CloudFront, S3 and cachingStatic assets served from the edge, `index.html` never cachedA cache key forwarding every cookie, so the hit rate is near zero
6Centralised logging in another accountThe workload cannot write to or delete its own audit trailTrails writing into the account they audit
7Pipeline-only deploymentA console change is reverted or fails on the next applySeveral console changes nobody had recorded
8Backups and a DR runbookA timed restore, and a drill someone else can run from the document"We take snapshots" with no restore ever performed

Together

text
# The three drills, with the numbers they are supposed to produce.

DRILL 1 — ZONE LOSS                          quarterly
  Method   remove eu-west-1b subnets from the ALB target group,
           and set the ASG's desired capacity for that zone to 0
  Measure  time to full capacity in eu-west-1a; error count
  Pass     errors return to baseline within the stated objective,
           and the remaining zone serves peak without saturation
  Found    ASG maximum was 4; peak needs 3 per zone, so a single
           zone could only reach 4 of the 6 required. Raised to 8.

DRILL 2 — RESTORE                            quarterly
  Method   restore the production database to a point in time
           into a new instance, in a non-production account
  Measure  wall-clock from initiating to accepting queries
  Pass     under the agreed RTO for the data tier
  Found    22 minutes for the restore, then 40 more to reconfigure
           the application. The real RTO was 62 minutes, not 22 —
           and only the drill would ever have shown the second part.

DRILL 3 — REBUILD FROM THE REPOSITORY        twice a year
  Method   deploy the whole environment into an empty account
  Measure  whether it completes, and what had to be done by hand
  Pass     it completes with no manual step outside the runbook
  Found    an ACM certificate created in the console two years
           ago, a Route 53 zone owned by nobody, and a KMS key
           policy naming a role that only exists in production.

The third drill is the one people skip and the one that finds
the most, because it is the only test of the claim that the
repository actually describes the system.

Remember: Everything from the repository, nothing from the console. Two zones with capacity for one, private data subnets, endpoints off the NAT path, and logs in another account. Then prove it three times: lose a zone, restore the database, and rebuild the environment in an empty account — each with a measured number and a date.

See also: redundancy health checks and replacement · testing recovery procedures · immutable over console changes · centralized logging not instance files · the reliability review

Advertisement