Deploy Django on AWS With the CDK

Deploy Django on AWS with the Cloud Development Kit in Python: an ApplicationLoadBalancedFargateService, RDS PostgreSQL, Secrets Manager, S3 media, and a migration task, with cdk diff and cdk deploy in CI.

Problem statement

Running Django on ECS Fargate involves a VPC, an ALB, a cluster, task definitions, IAM roles, security groups, RDS, secrets, and log groups. Defining all of that by hand in CloudFormation YAML is thousands of lines; doing it in the console is unrepeatable. The AWS CDK lets you write it in Python with high-level constructs: one ApplicationLoadBalancedFargateService creates the load balancer, service, task definition, and wiring, while you focus on the Django-specific parts.

Django stack with the CDKPython CDK appconstructscdk synthCloudFormationcdk deploystack updateFargate + RDSrunning DjangoHigh-level constructs turn a few dozen lines of Python into a full Fargate stack.
  1. npm install -g aws-cdk, then cdk init app --language python in infra/ and cdk bootstrap once per account and region.
  2. Create a Vpc, a DatabaseInstance (PostgreSQL) with generated credentials, and an S3 bucket for media.
  3. Use ecs_patterns.ApplicationLoadBalancedFargateService with your Django image, secrets from Secrets Manager, and a /health/ health check.
  4. Grant the task role access to the bucket and the database security group.
  5. Run migrations as a one-off Fargate task in the deploy pipeline before updating the service.
  6. Use cdk diff in pull requests and cdk deploy on merge.

Step-by-step solution

1. Initialise and bootstrap

mkdir infra && cd infra
cdk init app --language python
pip install -r requirements.txt
cdk bootstrap aws://123456789012/eu-west-1

2. Define the stack

# infra/infra/django_stack.py
from aws_cdk import Stack, Duration, RemovalPolicy, aws_ec2 as ec2, aws_ecs as ecs, \
    aws_ecs_patterns as patterns, aws_rds as rds, aws_s3 as s3, aws_secretsmanager as sm
from constructs import Construct

class DjangoStack(Stack):
    def __init__(self, scope: Construct, id: str, **kw):
        super().__init__(scope, id, **kw)
        vpc = ec2.Vpc(self, "Vpc", max_azs=2, nat_gateways=1)

        db = rds.DatabaseInstance(
            self, "Db", vpc=vpc,
            engine=rds.DatabaseInstanceEngine.postgres(version=rds.PostgresEngineVersion.VER_16),
            instance_type=ec2.InstanceType("t4g.small"),
            credentials=rds.Credentials.from_generated_secret("app"),
            database_name="app", multi_az=False,
            removal_policy=RemovalPolicy.SNAPSHOT, deletion_protection=True,
        )
        media = s3.Bucket(self, "Media", block_public_access=s3.BlockPublicAccess.BLOCK_ALL)
        app_secret = sm.Secret.from_secret_name_v2(self, "AppSecret", "prod/django")

        svc = patterns.ApplicationLoadBalancedFargateService(
            self, "Web", vpc=vpc, cpu=512, memory_limit_mib=1024, desired_count=2,
            task_image_options=patterns.ApplicationLoadBalancedTaskImageOptions(
                image=ecs.ContainerImage.from_asset("../"),
                container_port=8000,
                environment={"DJANGO_SETTINGS_MODULE": "config.settings.production",
                             "AWS_STORAGE_BUCKET_NAME": media.bucket_name},
                secrets={
                    "SECRET_KEY": ecs.Secret.from_secrets_manager(app_secret, "SECRET_KEY"),
                    "DB_PASSWORD": ecs.Secret.from_secrets_manager(db.secret, "password"),
                    "DB_HOST": ecs.Secret.from_secrets_manager(db.secret, "host"),
                },
            ),
            public_load_balancer=True,
        )
        svc.target_group.configure_health_check(path="/health/", healthy_http_codes="200")
        svc.target_group.set_attribute("deregistration_delay.timeout_seconds", "30")
        db.connections.allow_default_port_from(svc.service)
        media.grant_read_write(svc.task_definition.task_role)

ContainerImage.from_asset("../") builds the Dockerfile and pushes it to an ECR repository the CDK manages. Add a certificate and domain with the certificate and domain_name properties plus a Route 53 zone.

ApplicationLoadBalancedFargateService expands toApplication Load Balancerlistener, target groupECS cluster and servicedesired count, rolling deploysTask definitionimage, env, secrets, logsIAM rolesexecution role, task roleSecurity groupsALB to tasks wiringYou add the Django parts: database access, bucket grants, health pathdjango-deployment.com
Grant methods like grant_read_write write least-privilege IAM policies for you.

3. Settings that read the injected values

DATABASES = {"default": {
    "ENGINE": "django.db.backends.postgresql",
    "NAME": "app", "USER": "app",
    "PASSWORD": os.environ["DB_PASSWORD"], "HOST": os.environ["DB_HOST"],
    "CONN_MAX_AGE": 60, "OPTIONS": {"sslmode": "require"},
}}
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

4. Migrations

Run migrations with the same task definition and a command override before rolling out:

aws ecs run-task --cluster $CLUSTER --task-definition $TASKDEF --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SG]}" \
  --overrides '{"containerOverrides":[{"name":"web","command":["python","manage.py","migrate","--noinput"]}]}'

Export the cluster, task definition family, subnets, and security group as CfnOutputs so the pipeline can find them. Wait for the task to stop with exit code 0 before deploying.

5. CI workflow

- run: npx cdk diff     # on pull requests
- run: npx cdk deploy --require-approval never   # on main

Give CI an IAM role through GitHub OIDC with permission to assume the CDK bootstrap roles. Review cdk diff output for replacements of stateful resources.

CDK stack hygieneDatabase has deletion protectionand snapshot removal policySecrets from Secrets Managernever plain environmentHealth check on /health/deregistration delay tunedMigration task before rolloutexit code checkedcdk diff reviewed in PRswatch for replacementsStateful resources need the most protection from accidental replacementdjango-deployment.com
A changed construct ID can replace a database; diff output shows it in advance.

Verification check: cdk diff shows no changes after deploy, the ALB DNS name (or your domain) serves the app over HTTPS, the target group lists two healthy tasks, and the migration task's logs in CloudWatch end with OK.

Explanation

The CDK is a framework that synthesises CloudFormation templates from code. Its constructs come in levels: L1 maps one-to-one to CloudFormation resources, L2 adds sensible defaults and helper methods (grant_read_write, connections), and L3 patterns combine many resources into a common architecture. For Django on Fargate, the L3 pattern covers the generic web-service plumbing, leaving the application-specific integration explicit and short. CloudFormation then applies changes transactionally and rolls back failed updates.

Edge cases / notes

  • Construct IDs determine logical IDs; renaming one can replace the resource. Never rename the database construct.
  • Celery workers use QueueProcessingFargateService or a plain FargateService without a load balancer.
  • Costs. NAT gateways are a significant fixed cost; one is enough for staging.
  • Context and environments. Use separate stacks per environment with explicit account and region.

Next steps

FAQ

Do I need Node.js to use the CDK with Python?

Yes, the CDK CLI runs on Node.js, while your stack code is Python.

How do migrations run with a CDK deployment?

As a one-off ECS task with a command override, run by the pipeline before the service update.

Can the CDK build my Docker image?

Yes. ContainerImage.from_asset builds the Dockerfile and pushes it to a managed ECR repository.

CDK or Terraform?

CDK is AWS-only and synthesises CloudFormation; Terraform is multi-cloud. Both are good for Django infrastructure.

Keep going