Deploy Django on AWS ECS Fargate

Run Django on AWS ECS Fargate with an ECR image, Secrets Manager credentials, an Application Load Balancer, one-off migration tasks, and safe rolling deploys.

Problem statement

Amazon ECS on Fargate runs containers without servers to patch. For a Django team that already builds a Docker image, it is a natural step up from a single VM: an Application Load Balancer spreads traffic across tasks, deploys replace tasks gradually, and logs go to CloudWatch.

The difficult parts are not the containers themselves. They are:

  • getting secrets into the task without baking them into the image or task definition
  • running migrations exactly once per release, before new code serves traffic
  • making ALB health checks pass with Django's ALLOWED_HOSTS validation
  • rolling back automatically when a new release crashes on boot

This guide assumes you have a working production Dockerfile and an AWS account with a VPC. It walks through ECR, the task definition, the service, migrations, and deploy automation with the AWS CLI.

Django on ECS FargateECR imageimmutable tagTask definitionsecrets, loggingECS servicebehind an ALBMigration taskrun once per releaseMigrations run as a one-off task before the service switches to the new revision.

Quick answer

  1. Build a production image that runs Gunicorn on port 8000 and push it to Amazon ECR with an immutable tag such as the Git SHA.
  2. Store DJANGO_SECRET_KEY and database credentials in AWS Secrets Manager.
  3. Register a task definition that references the image, maps secrets with valueFrom, and logs with the awslogs driver.
  4. Create an ECS service on Fargate behind an ALB target group with a /healthz/ health check.
  5. Before each service update, run manage.py migrate as a one-off run-task and wait for it to exit with code 0.
  6. Update the service to the new task definition revision with the deployment circuit breaker and rollback enabled.

Step-by-step solution

1. Production image

The image must be self-contained: dependencies installed, static files collected, and Gunicorn as the default command.

FROM python:3.13-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN DJANGO_SECRET_KEY=build-only-not-secret \
    python manage.py collectstatic --noinput

RUN useradd --create-home --uid 10001 app
USER app

EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--access-logfile", "-"]

The throwaway DJANGO_SECRET_KEY only exists so settings import during the build. It is not used at runtime, where the real key arrives from Secrets Manager. Serve the collected files with WhiteNoise, or upload them to S3 and CloudFront.

2. Push to ECR

AWS_REGION=eu-west-1
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REPO=django-app
IMAGE_TAG=$(git rev-parse --short HEAD)
REGISTRY=$ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com

aws ecr create-repository --repository-name $REPO \
  --image-tag-mutability IMMUTABLE --region $AWS_REGION

aws ecr get-login-password --region $AWS_REGION \
  | docker login --username AWS --password-stdin $REGISTRY

docker build --platform linux/amd64 -t $REGISTRY/$REPO:$IMAGE_TAG .
docker push $REGISTRY/$REPO:$IMAGE_TAG

Immutable tags mean a tag always refers to the same image, so a task definition revision is a reliable record of what ran. Build for the CPU architecture you configure in the task definition; linux/amd64 matches the default X86_64 runtime platform.

3. Store secrets

aws secretsmanager create-secret \
  --name prod/django \
  --secret-string '{"DJANGO_SECRET_KEY":"generated","DB_PASSWORD":"generated"}'
secrets injected at task start"executionRoleArn": "…/ecsTaskExecutionRole","secrets": [{"name": "DJANGO_SECRET_KEY","valueFrom": "arn:…:secret:prod/django:DJANGO_SECRET_KEY::"}]1231This role, not the task role, must be allowed to read the secret.2Becomes an ordinary environment variable inside the container.3One JSON key from the secret; the image never contains it.A missing permission here shows up as ResourceInitializationErrordjango-deployment.com
Secrets are read when a task starts, so rotating one needs a new deployment.

The task execution role needs secretsmanager:GetSecretValue on this secret (and kms:Decrypt if you use a customer managed key). The execution role is what ECS uses to pull the image and inject secrets; the separate task role is what your Django code uses to call AWS APIs such as S3.

4. Task definition

taskdef.json:

{
  "family": "django-web",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "512",
  "memory": "1024",
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::123456789012:role/djangoTaskRole",
  "containerDefinitions": [
    {
      "name": "web",
      "image": "123456789012.dkr.ecr.eu-west-1.amazonaws.com/django-app:abc1234",
      "essential": true,
      "portMappings": [{ "containerPort": 8000, "protocol": "tcp" }],
      "environment": [
        { "name": "DJANGO_SETTINGS_MODULE", "value": "config.settings" },
        { "name": "DJANGO_ALLOWED_HOSTS", "value": "www.example.com" },
        { "name": "DB_HOST", "value": "mydb.abcdefghij.eu-west-1.rds.amazonaws.com" },
        { "name": "DB_NAME", "value": "django" },
        { "name": "DB_USER", "value": "django" }
      ],
      "secrets": [
        {
          "name": "DJANGO_SECRET_KEY",
          "valueFrom": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:prod/django-AbCdEf:DJANGO_SECRET_KEY::"
        },
        {
          "name": "DB_PASSWORD",
          "valueFrom": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:prod/django-AbCdEf:DB_PASSWORD::"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/django-web",
          "awslogs-region": "eu-west-1",
          "awslogs-stream-prefix": "web"
        }
      }
    }
  ]
}

The valueFrom suffix :DJANGO_SECRET_KEY:: selects one JSON key from the secret. Secrets are resolved when a task starts, so rotating a secret requires new tasks to pick it up.

Create the log group, then register:

aws logs create-log-group --log-group-name /ecs/django-web
aws ecs register-task-definition --cli-input-json file://taskdef.json

5. Health checks and ALLOWED_HOSTS

ALB health checks connect to each task's private IP, and the Host header is typically that IP. Django rejects it with 400, so the target never becomes healthy. Answer the health check path in a middleware placed before anything that calls request.get_host():

from django.http import HttpResponse


class HealthCheckMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.path == "/healthz/":
            return HttpResponse("ok")
        return self.get_response(request)

Put it first in MIDDLEWARE. Configure the target group with target type ip, protocol HTTP, port 8000, and health check path /healthz/. Fargate tasks in awsvpc mode require ip targets.

6. Security groups

  • ALB security group: inbound 443 (and 80 for redirect) from the internet.
  • Task security group: inbound 8000 only from the ALB security group.
  • RDS security group: inbound 5432 only from the task security group.
each tier only accepts the tier in frontAcceptsFromALB443 (and 80)the internetFargate tasks8000✓ALB security group onlyRDS PostgreSQL5432✓task security group onlyRules reference security groups, not IP ranges, so tasks can come and godjango-deployment.com
Task IP addresses change on every deploy, which is why IP-based rules break on Fargate.

Run tasks in private subnets with a NAT gateway or VPC endpoints for ECR, CloudWatch Logs, and Secrets Manager, so they can pull images and secrets without public IPs.

7. Create the cluster and service

aws ecs create-cluster --cluster-name django-prod

aws ecs create-service \
  --cluster django-prod \
  --service-name web \
  --task-definition django-web \
  --desired-count 2 \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[subnet-aaa,subnet-bbb],securityGroups=[sg-tasks],assignPublicIp=DISABLED}" \
  --load-balancers "targetGroupArn=arn:aws:elasticloadbalancing:eu-west-1:123456789012:targetgroup/django-web/abc,containerName=web,containerPort=8000" \
  --health-check-grace-period-seconds 60 \
  --deployment-configuration "maximumPercent=200,minimumHealthyPercent=100,deploymentCircuitBreaker={enable=true,rollback=true}"
  • minimumHealthyPercent=100 and maximumPercent=200 start new tasks before stopping old ones.
  • The deployment circuit breaker stops a deployment whose tasks keep failing and rolls back to the last completed deployment.
  • The grace period gives Gunicorn time to boot before failed health checks count against the task.

8. Run migrations as a one-off task

Never run migrate in the container command of the web service. With two or more tasks starting together, each would try to migrate.

a release on ECS1Push the image to ECRtagged with the commit SHA2Register a task definition revisionpointing at that tag3Run migrate as a one-off tasksame image, override command4Update the servicerolling replacement5Wait for services-stabletargets healthy in the ALBMigrations never run in the web container commanddjango-deployment.com
Rolling back is updating the service to the previous task definition revision.
TASK_ARN=$(aws ecs run-task \
  --cluster django-prod \
  --launch-type FARGATE \
  --task-definition django-web \
  --network-configuration "awsvpcConfiguration={subnets=[subnet-aaa],securityGroups=[sg-tasks],assignPublicIp=DISABLED}" \
  --overrides '{"containerOverrides":[{"name":"web","command":["python","manage.py","migrate","--noinput"]}]}' \
  --query 'tasks[0].taskArn' --output text)

aws ecs wait tasks-stopped --cluster django-prod --tasks "$TASK_ARN"

aws ecs describe-tasks --cluster django-prod --tasks "$TASK_ARN" \
  --query 'tasks[0].containers[0].exitCode' --output text

Only continue the release if the exit code is 0. Because the migration task uses the new task definition revision, it runs the new code's migrations with the same secrets and network access as the web tasks.

9. Deploy a new release

A full release script:

set -euo pipefail

IMAGE_TAG=$(git rev-parse --short HEAD)
docker build --platform linux/amd64 -t $REGISTRY/$REPO:$IMAGE_TAG .
docker push $REGISTRY/$REPO:$IMAGE_TAG

# Render a new task definition revision with the new image
jq --arg img "$REGISTRY/$REPO:$IMAGE_TAG" \
  '.containerDefinitions[0].image = $img' taskdef.json > taskdef.rendered.json
REVISION_ARN=$(aws ecs register-task-definition \
  --cli-input-json file://taskdef.rendered.json \
  --query 'taskDefinition.taskDefinitionArn' --output text)

# Migrate with the new revision (step 8, using $REVISION_ARN), then:
aws ecs update-service --cluster django-prod --service web \
  --task-definition "$REVISION_ARN"

aws ecs wait services-stable --cluster django-prod --services web

services-stable waits until the deployment reaches its desired state; it gives up after a fixed number of polls, so treat a timeout as "investigate", not as success.

10. Verify

aws ecs describe-services --cluster django-prod --services web \
  --query 'services[0].deployments'
aws logs tail /ecs/django-web --follow
curl -I https://www.example.com/healthz/
curl -I https://www.example.com/

Verification check:

  • one PRIMARY deployment with rolloutState COMPLETED
  • all targets healthy in the target group
  • CloudWatch logs show Gunicorn workers booting without tracebacks
  • the site responds over HTTPS with the new release

Rollback

With the circuit breaker enabled, failed deployments roll back automatically. For a release that boots fine but is functionally broken, update the service to the previous task definition revision:

aws ecs update-service --cluster django-prod --service web \
  --task-definition django-web:41

Code rolls back; the database does not. Keep migrations backward compatible so the previous revision can run against the migrated schema.

Explanation

ECS separates what runs (the task definition, an immutable, versioned document) from how many run and where (the service). Each release is a new task definition revision pointing at a new image tag, so rollback is just pointing the service at an earlier revision.

Migrations are a release step, not a runtime step. Running them in a one-off task that shares the task definition gives you the same image, network, and secrets while guaranteeing a single execution and an explicit exit code you can gate the deploy on.

Edge cases / notes

Tasks stop with CannotPullContainerError

Tasks in private subnets need a route to ECR: a NAT gateway or interface endpoints for ecr.api, ecr.dkr, plus an S3 gateway endpoint for image layers.

ResourceInitializationError fetching secrets

The execution role lacks secretsmanager:GetSecretValue, or the task cannot reach Secrets Manager from its subnet.

Static and media files

Task storage is ephemeral. Use WhiteNoise or S3 for static files and S3 for uploads.

Background workers

Run Celery as a separate ECS service using the same image with a different command, so web and worker scale independently.

Troubleshooting stopped tasks

When a task stops, ECS records a stop reason. It is the fastest way to classify the failure:

aws ecs list-tasks --cluster django-prod --service-name web --desired-status STOPPED \
  --query 'taskArns[0:3]' --output text

aws ecs describe-tasks --cluster django-prod --tasks <task-arn> \
  --query 'tasks[0].{stopped:stoppedReason,containers:containers[].{name:name,exit:exitCode,reason:reason}}'
reading stoppedReasonStopped reasonUsual causeCannotPullContainerErrorno route to ECR: NAT,endpoints, or public IPResourceInitializationErrorexecution role cannot read the secretEssential container exitedDjango crashed at start;read CloudWatch logsTask failed ELB health checkshealth path returns 400: ALLOWED_HOSTSdescribe-tasks shows the reason; the container log shows the tracebackdjango-deployment.com
Stopped tasks disappear from the console after about an hour, so check promptly.
Stop reason or exit codeMeaningWhere to look next
Essential container in task exited, exit code 1 or 3Django or Gunicorn crashed on bootCloudWatch logs for the task's log stream
Task failed ELB health checkscontainer runs, but health checks failtarget group health reason; ALLOWED_HOSTS; security group from ALB to port 8000
OutOfMemoryError: Container killed due to memory usagetask memory limit exceededreduce Gunicorn workers or raise task memory
CannotPullContainerErrorimage missing or no network path to ECRimage tag, execution role, NAT or VPC endpoints
ResourceInitializationErrorsecrets or logs could not be set upexecution role permissions, Secrets Manager or Logs endpoints

Stopped tasks are only retained for a limited time, so check soon after a failed deployment. CloudWatch logs persist according to the log group retention you set; set an explicit retention (for example 30 days) so logs do not accumulate forever.

Debugging inside a running task

ECS Exec gives you a shell in a running container without SSH:

aws ecs update-service --cluster django-prod --service web --enable-execute-command --force-new-deployment
aws ecs execute-command --cluster django-prod --task <task-arn> --container web \
  --interactive --command "python manage.py shell"

It requires the task role to allow the SSM messages actions and the Session Manager plugin on your machine. Restrict who can call ecs:ExecuteCommand, because a shell in the container has access to all of its secrets.

Sizing tasks

Fargate CPU and memory are chosen per task from supported combinations. A practical approach for Django:

  • start with 0.5 vCPU and 1 GB for two or three Gunicorn workers
  • measure memory per worker in CloudWatch Container Insights under load
  • scale horizontally with Service Auto Scaling on CPU or ALB request count per target, instead of making tasks very large

Keep total database connections in mind: tasks × workers × threads must stay below the database limit, or put a connection pooler in front of it.

Common mistakes

  • Using the latest tag in the task definition, which makes rollbacks and audits ambiguous.
  • Running migrations from the web container command, which runs them in every task.
  • Giving the task role broad permissions (for example s3:* on all buckets) instead of the specific bucket and actions the app needs.
  • Forgetting log retention, which leads to unbounded CloudWatch Logs costs.

Next steps

FAQ

How do I run Django migrations on ECS Fargate?

Run a one-off task with aws ecs run-task, using the new task definition revision and a command override of python manage.py migrate --noinput. Wait for it to stop, check the exit code, and only then update the service.

How do I pass secrets to a Django container on ECS?

Use the secrets section of the container definition with valueFrom pointing at AWS Secrets Manager or SSM Parameter Store. ECS injects them as environment variables when the task starts, using the task execution role.

Why are my Fargate targets unhealthy even though Django starts?

The ALB health check usually sends the task's IP as the Host header, which Django rejects with 400. Serve the health check path before host validation, and make sure the security group allows the ALB to reach port 8000.

Should I use Fargate or Elastic Beanstalk for Django?

Fargate suits teams already shipping Docker images who want explicit control over tasks, networking, and deploys. Elastic Beanstalk hides more infrastructure and deploys code bundles, which is simpler but less flexible.

Keep going