Auto Scale a Django Service on Amazon ECS

Configure ECS service auto scaling for Django web and Celery services: target tracking on CPU or ALB requests per target, step scaling on SQS or custom queue metrics, sensible min and max tasks, cooldowns, database connection limits, and graceful shutdown.

Problem statement

Your Django app runs on ECS (Fargate or EC2) with a fixed number of tasks. At peak hours response times climb; at night you pay for idle tasks. Celery queues back up during imports and sit empty otherwise. ECS service auto scaling adjusts task counts automatically, but scaling on the wrong metric, without limits that respect the database, or without graceful shutdown leads to flapping, dropped requests, and connection exhaustion.

ECS auto scaling for DjangoMetricCPU, requests, queuePolicytarget or stepTask countmin to maxSafe scalingDB limits, SIGTERMScale web on load and workers on queue depth, within database limits.
  1. Register the service as a scalable target with min (at least 2 for web) and max capacity.
  2. Web: target tracking on ALBRequestCountPerTarget or average CPU (for example 60 percent).
  3. Celery: scale on queue depth (SQS ApproximateNumberOfMessagesVisible, or a custom CloudWatch metric for Redis queues).
  4. Cap max so total database connections (tasks × workers × threads) stay under the database limit, or add RDS Proxy.
  5. Handle SIGTERM gracefully and set the ALB deregistration delay so scale-in does not drop requests.

Step-by-step solution

1. Register the scalable target

aws application-autoscaling register-scalable-target \
  --service-namespace ecs \
  --resource-id service/prod/shop-web \
  --scalable-dimension ecs:service:DesiredCount \
  --min-capacity 2 --max-capacity 12

Two tasks minimum gives redundancy across availability zones and during deployments.

2. Scale web tasks on requests or CPU

aws application-autoscaling put-scaling-policy \
  --service-namespace ecs --resource-id service/prod/shop-web \
  --scalable-dimension ecs:service:DesiredCount \
  --policy-name web-requests --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration '{
    "TargetValue": 300,
    "PredefinedMetricSpecification": {
      "PredefinedMetricType": "ALBRequestCountPerTarget",
      "ResourceLabel": "app/shop-alb/abc123/targetgroup/shop-web/def456"
    },
    "ScaleOutCooldown": 60,
    "ScaleInCooldown": 300
  }'

Requests per target tracks load directly for I/O-bound Django apps, where CPU stays low even when workers are saturated. For CPU-heavy apps, ECSServiceAverageCPUUtilization with a 60 percent target works. Find the right target value with a load test: the request rate per task at which p95 latency starts to rise.

metric and when to use itServiceNotesALBRequestCountPerTargetwebbest for I/O-bound viewsAverage CPUweb or workersCPU-heavy workAverage memory!rarelyPython rarely frees RAMSQS queue depthCelery on SQSstep scalingCustom Redis queueCelery on Redispublish to CloudWatchScale web on demand and workers on backlogdjango-deployment.com
Memory-based scaling often scales out and never scales back in.

3. Scale Celery workers on queue depth

With SQS as the broker, use step scaling on ApproximateNumberOfMessagesVisible, or target tracking on backlog per task (messages divided by running tasks via metric math). With Redis, publish the queue length as a custom metric from a small scheduled task:

import boto3, redis
r = redis.from_url(os.environ["REDIS_URL"])
boto3.client("cloudwatch").put_metric_data(
    Namespace="Shop/Celery",
    MetricData=[{"MetricName": "QueueLength", "Dimensions": [{"Name": "Queue", "Value": "default"}],
                 "Value": r.llen("default")}],
)

Scale out when the backlog per worker exceeds what one task clears in a few minutes; scale in slowly.

4. Respect database limits

Every new task opens database connections: Gunicorn workers × threads per web task, Celery concurrency per worker task. Compute the maximum:

web:    12 tasks x 3 workers x 4 threads = 144
celery:  8 tasks x 4 concurrency         =  32
total                                      176 (+ migrations, admin, cron)

If that exceeds max_connections, lower max capacity, reduce per-task concurrency, or add RDS Proxy or PgBouncer.

5. Scale in without dropping requests

On scale-in, ECS deregisters the task from the target group, waits the deregistration delay, then sends SIGTERM and, after stopTimeout, SIGKILL. Gunicorn finishes in-flight requests on SIGTERM within its graceful_timeout. Set the target group's deregistration delay (for example 30 seconds) and stopTimeout longer than your slowest request. For Celery, acks-late with idempotent tasks makes killed tasks safe to retry.

graceful scale-inPolicy lowers countafter cooldownTask deregisteredno new requestsDeregistration delayin-flight finishSIGTERM to Gunicorngraceful exitSIGKILL if too slowafter stopTimeoutTimeouts must cover the slowest request to avoid dropped responsesdjango-deployment.com
A long scale-in cooldown prevents flapping after short spikes.

Explanation

Application Auto Scaling adjusts the ECS service's desired count based on CloudWatch alarms tied to your policies. Target tracking keeps a metric near a target value by adding or removing tasks; step scaling reacts to alarm thresholds with defined increments. Because each Django task holds database connections and in-flight requests, safe autoscaling depends on capacity limits that protect the database and on shutdown behavior that drains tasks before stopping them.

Edge cases / notes

  • Deployments. Scaling and deployments interact; during a rolling deployment, ECS honors the desired count changes, so set minimumHealthyPercent to keep capacity.
  • Cold starts. Fargate tasks take time to pull images and start; keep images small and scale out early with lower targets.
  • Scheduled scaling. For predictable peaks (business hours, campaigns), scheduled actions raise the minimum ahead of time.

Next steps

FAQ

Should Django web tasks scale on CPU or requests?

Requests per target for most Django apps, which are I/O-bound; CPU for compute-heavy apps.

How do I scale Celery on ECS?

On queue depth: SQS metrics directly, or a custom CloudWatch metric for Redis queue length.

Why did scaling out cause database errors?

New tasks added connections beyond the database limit. Cap max tasks or add a pooler like RDS Proxy.

How do I avoid dropping requests during scale-in?

Use an ALB deregistration delay and a stop timeout longer than your slowest request, with Gunicorn handling SIGTERM gracefully.

Keep going