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_HOSTSvalidation - 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.
Quick answer
- 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.
- Store
DJANGO_SECRET_KEYand database credentials in AWS Secrets Manager. - Register a task definition that references the image, maps secrets with
valueFrom, and logs with theawslogsdriver. - Create an ECS service on Fargate behind an ALB target group with a
/healthz/health check. - Before each service update, run
manage.py migrateas a one-offrun-taskand wait for it to exit with code 0. - 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"}'
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.
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=100andmaximumPercent=200start 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.
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
PRIMARYdeployment withrolloutStateCOMPLETED - 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}}'
| Stop reason or exit code | Meaning | Where to look next |
|---|---|---|
Essential container in task exited, exit code 1 or 3 | Django or Gunicorn crashed on boot | CloudWatch logs for the task's log stream |
Task failed ELB health checks | container runs, but health checks fail | target group health reason; ALLOWED_HOSTS; security group from ALB to port 8000 |
OutOfMemoryError: Container killed due to memory usage | task memory limit exceeded | reduce Gunicorn workers or raise task memory |
CannotPullContainerError | image missing or no network path to ECR | image tag, execution role, NAT or VPC endpoints |
ResourceInitializationError | secrets or logs could not be set up | execution 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
latesttag 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
- Build a lean, secure image with Build Smaller, Faster Django Docker Images.
- Review container basics in How to Dockerize a Django App for Production.
- Debug unhealthy targets with Fix Load Balancer Health Checks Failing for Django.
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.