Skip to content

A production Django platform on AWS, built across three environments (QA, UAT, production) with ECS Fargate, RDS PostgreSQL, ElastiCache Redis and S3. This note records the architecture and the problems that actually came up. Services are explained in 3. AWS Compute, 5. AWS Networking and 4. AWS Storage and Databases.

Services at a glance

Layer Service Role
DNS + TLS Route 53, ACM Names and certificates (AWS Route 53 Deep Dive)
Edge CloudFront, AWS WAF Caching, TLS, firewall (AWS CloudFront Deep Dive, 6. WAF in Production)
Load balancing Application Load Balancer Health-checked routing (AWS Elastic Load Balancing Deep Dive)
Compute ECS on Fargate Web service, Celery worker service, Celery Beat
Images ECR Container registry
Database RDS PostgreSQL (+ PostGIS) Private subnet, backups, Multi-AZ in production
Cache / broker ElastiCache Redis Cache, Celery broker, Channels layer
Files S3 (+ EFS where a shared filesystem is needed) Static, media, uploads (AWS S3 Deep Dive)
Config SSM Parameter Store / Secrets Manager Settings and secrets
Infra as code CloudFormation Repeatable stacks per environment
Observability CloudWatch Logs and alarms Logs, health, alerts

Architecture

flowchart TB
    U[Users] --> R53[Route 53]
    R53 --> CF[CloudFront + WAF]
    CF --> ALB[Application Load Balancer]
    subgraph VPC
      subgraph Public subnets
        ALB
        NAT[NAT gateway]
      end
      subgraph Private subnets
        WEB[ECS service: web / Gunicorn]
        WRK[ECS service: Celery workers]
        BEAT[ECS service: Celery Beat]
        DB[(RDS PostgreSQL)]
        REDIS[(ElastiCache Redis)]
      end
    end
    ALB --> WEB
    WEB --> DB
    WEB --> REDIS
    WRK --> DB
    WRK --> REDIS
    BEAT --> REDIS
    WEB --> S3[(S3 media and static)]
    WRK --> S3
  • Public subnets hold only the load balancer and NAT; everything else is private
  • Separate ECS services for web, workers and the scheduler, so each scales and deploys independently
  • One task definition per role, same image, different command (gunicorn ..., celery worker, celery beat)

Security Group Chain

Each hop accepts traffic only from the previous one:

Resource Inbound allowed from
ALB 443 from CloudFront (managed prefix list) or the internet
Web tasks App port from the ALB security group only
RDS 5432 from the web and worker security groups only
Redis 6379 from the web and worker security groups only

Every component has to be wired exactly right: a wrong security group means no database; a wrong subnet means ECS tasks cannot pull images.

Why tasks fail to start (checklist)

Symptom Cause
CannotPullContainerError Private subnet with no route to ECR: add a NAT gateway or VPC endpoints for ecr.api, ecr.dkr, the S3 gateway endpoint and logs
Task stops immediately Container command or env var wrong; read the CloudWatch log stream
Task cycles (start, fail, replace) Health check failing; see below
Cannot reach DB or Redis Security group missing a rule, wrong subnet group
AccessDenied on S3 or secrets Task role missing permission (execution role is for pulling images and writing logs; task role is for the app)

Two roles: task execution role (ECR pull, logs, secret injection) and task role (what the application itself may do): 2. AWS IAM and Security.


Health Checks as Diagnostics

A /health/ endpoint that checks the critical dependencies, so a cycling task tells you which one is unreachable:

Check Verifies
Database Can run SELECT 1
Cache Redis read and write
Storage S3 reachable with the task role
Migrations No unapplied migrations
Workers At least one Celery worker responds (inspect ping)
def health(request):
    checks = {
        "database": check_db(),
        "cache": check_cache(),
        "storage": check_s3(),
        "migrations": check_migrations(),
        "celery": check_celery(),
    }
    status = 200 if all(checks.values()) else 503
    return JsonResponse(checks, status=status)

Lessons: - A missing security-group rule or IAM permission fails a check and puts the task in a replacement loop; the JSON body says which dependency broke - Use a fast liveness path for the ALB target group and the deep check for diagnostics, so a slow dependency does not cause a mass restart - Allow a start period / grace time so slow starts (migrations, warm-up) are not killed - Order start-up: database and cache must exist before the app; workers after the web service


Timeouts Across the Whole Path

Large uploads (tens of MB) broke silently because every layer has its own timeout, and the smallest one wins:

flowchart LR
    B[Browser] --> CF[CloudFront: origin response timeout]
    CF --> ALB[ALB: idle timeout, default 60 s]
    ALB --> G[Gunicorn: --timeout, default 30 s]
    G --> D[Django view]
Layer Setting
CloudFront Origin response timeout (and keep-alive) on the origin
ALB Idle timeout attribute
Gunicorn --timeout and worker class (sync workers block on slow clients)
Django DATA_UPLOAD_MAX_MEMORY_SIZE, FILE_UPLOAD_MAX_MEMORY_SIZE
WAF Body size inspection limit (6. WAF in Production)

Rule: tune every layer consistently, keeping each downstream timeout at least as long as the one in front of it. Better still, do not send big files through the app: use a presigned S3 upload.

Direct-to-S3 uploads (presigned POST)

import boto3
s3 = boto3.client("s3")
post = s3.generate_presigned_post(
    Bucket="my-unique-bucket-12345",
    Key="uploads/${filename}",
    Conditions=[["content-length-range", 1, 50 * 1024 * 1024]],
    ExpiresIn=600,
)
# return post["url"] and post["fields"]; the browser POSTs the file straight to S3

The app only issues a short-lived signed form; the bytes never touch Gunicorn, ALB or CloudFront limits. Add a bucket CORS rule for the site's origin.


Repeatable Environments with CloudFormation

Three environments from one set of templates and a parameter file per environment:

infra/
  network.yaml           VPC, subnets, route tables, NAT, endpoints
  data.yaml              RDS, ElastiCache, S3
  service.yaml           ECS cluster, task definitions, services, ALB
  params/qa.json  params/uat.json  params/prod.json
scripts/
  push-env.sh            store env vars / secrets for an environment
  push-image.sh          build, tag, push the image to ECR
  deploy-env-addons.sh   buckets, queues, alarms
  deploy-all.sh          everything in order
  release.sh             roll the service to a new image
  • One script per concern, called by a thin deploy-all, keeps each step re-runnable (CloudFormation, Bicep and ARM Templates)
  • Environments differ by parameters (sizes, counts, domain), not by template copies
  • Keep QA / UAT / production the same shape so a bug found in QA is the one that would hit production

Migrating off a generator tool

AWS announced end-of-life for the Copilot CLI (mid-2026). Moving live services off it meant: 1. Reverse-engineer the CloudFormation stacks it generated 2. Extract the reusable patterns (task definition, service, ALB rules) 3. Rebuild them as standalone templates with per-environment parameter files 4. Drive them with small DRY bash scripts split by concern

Lesson: a wrapper CLI is convenient until it is deprecated. Keep the underlying templates in your repository from the start. See Infrastructure as Code.


Data and Region Choices

  • Keep personal data in-country by choosing the right Region (for example af-south-1, Cape Town, for POPIA-sensitive workloads) and checking that every service you use is available there
  • RDS: private subnet, automated backups, Multi-AZ in production, parameter groups, PostGIS extension for geospatial data
  • Redis does three jobs here (cache, Celery broker, Channels layer): use separate databases or key prefixes so they stay isolated (Celery and Message Brokers, Async Django - ASGI, Channels and SSE)

Lessons Worth Keeping

Lesson Detail
Own the estate end to end once Wiring VPC, subnets, security groups, RDS, Redis, ECS, ALB, S3, Route 53 and ACM by hand teaches what each dependency needs
Production controls in every environment WAF, TLS, headers and limits identical from QA onwards (6. WAF in Production)
Health checks that explain themselves Return per-dependency results
Tune the whole request path Browser to CloudFront to ALB to app
Presigned uploads for big files Keep large bodies off the app tier
Templates in git, not in a tool's cache Survive tool deprecation
Separate task and execution roles Least privilege