Oct 26, 20268 min read

How to Migrate a Django App from Heroku to AWS

Moving a Django app's web dyno, worker, Heroku Postgres, config vars and Scheduler jobs to your own AWS account, with Encore or with ECS, RDS and Terraform, and copying the data across with pg_dump.

Django apps on Heroku usually run a web dyno, a worker with Redis, Heroku Postgres, config vars and a Scheduler job. Heroku moved to a sustaining engineering model in February 2026, and on AWS each of those pieces becomes a separate service with its own networking and IAM.

This guide moves a Django signups app with all five pieces to AWS, first with Encore and then by hand with ECS on Fargate, RDS and Terraform. It also covers copying the database across.

What needs a new home:

  • The web dyno and its Procfile.
  • The worker dyno and its Redis broker.
  • Heroku Postgres and DATABASE_URL.
  • Config vars such as SECRET_KEY.
  • Heroku Scheduler jobs.

Heroku to AWS mapping at a glance

HerokuEncoreManual AWS setup
Web dynoFargate service from the codeECS service, task definition, load balancer
Worker dyno with Redispubsub.Topic and Subscription, SNS and SQSCelery worker service and ElastiCache
Heroku Postgrespostgres.Database, RDSRDS, subnet group, security group
Config varssecrets.SharedSecret, stored per environmentSecrets Manager and task definition references
Heroku Schedulercron.Job calling a routeEventBridge Scheduler and a task
Release phaseencore exec running migrateOne-off ECS task in the pipeline

Encore: Heroku to your AWS account from the code

Encore

Encore combines the infrastructure that Heroku add-ons used to provide, declared in your Django code, with a local environment and a deployment to your own AWS account, so the database, secrets, queue and schedule run under encore run and appear as RDS, Secrets Manager, SNS and SQS after deploy. It is an infrastructure SDK added to the existing project, and the models, views and Django migrations do not change.

Encore declares infrastructure in Python code, runs it locally and provisions it in your AWS or GCP account
Infrastructure declared in Python, running locally under encore run and provisioned in your own AWS or GCP account on deploy.

Everything the Heroku app got from add-ons and dashboard settings is declared in one module:

from encore import cron, pubsub, secrets from encore.storage import postgres # Replaces the Heroku Postgres add-on and DATABASE_URL. db = postgres.Database("web") # Replaces the SECRET_KEY config var. django_secret_key = secrets.SharedSecret[str]("DjangoSecretKey") # Replaces the worker dyno and its Redis broker. signed_up = pubsub.Topic("signed-up") # Replaces the Heroku Scheduler job. Encore sends POST /internal/daily-digest. daily_digest = cron.Job( "daily-digest", path="/internal/daily-digest", title="Send the daily signup digest", schedule="0 7 * * *", )

settings.py reads the secret key from the declared secret and builds DATABASES from the declared database, which replaces dj_database_url.config() and the config vars:

from psycopg.conninfo import conninfo_to_dict from signups.resources import db, django_secret_key SECRET_KEY = django_secret_key.value # The SDK supplies the connection details for the declared database. # Parse with psycopg so escaping and connection options are preserved. connection = conninfo_to_dict(db.connection_string) DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": connection.pop("dbname"), "USER": connection.pop("user", ""), "PASSWORD": connection.pop("password", ""), "HOST": connection.pop("host", ""), "PORT": connection.pop("port", ""), "OPTIONS": connection, } }

The Celery task that sent welcome emails becomes a subscriber. A welcomed flag on the model makes it safe to receive the same message twice:

from django.db import models class Signup(models.Model): email = models.EmailField(unique=True) created_at = models.DateTimeField(auto_now_add=True) welcomed = models.BooleanField(default=False)

The subscriber reads the event and updates the row, and checks the flag first so a redelivered message does nothing:

import logging from encore import pubsub from .models import Signup from .resources import signed_up logger = logging.getLogger(__name__) async def send_welcome(msg: pubsub.Message[dict]) -> None: signup = await Signup.objects.aget(pk=msg.data["signup_id"]) # Delivery is at least once, so a repeated message is skipped. if signup.welcomed: return logger.info("sending welcome email to %s", signup.email) signup.welcomed = True await signup.asave(update_fields=["welcomed"]) welcome = pubsub.Subscription[dict](signed_up, "send-welcome", handler=send_welcome)

The signup view publishes only when a new row is created, and the digest view is the route the cron job calls:

import json from datetime import timedelta from django.http import HttpRequest, JsonResponse from django.utils import timezone from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from .models import Signup from .resources import signed_up @csrf_exempt # Stateless JSON API example without cookie auth. @require_POST async def create_signup(request: HttpRequest) -> JsonResponse: email = json.loads(request.body)["email"] signup, created = await Signup.objects.aget_or_create(email=email) if created: await signed_up.publish({"signup_id": signup.pk}) return JsonResponse({"id": signup.pk}, status=201 if created else 200) @csrf_exempt # Called by the cron job. @require_POST async def daily_digest(request: HttpRequest) -> JsonResponse: since = timezone.now() - timedelta(days=1) count = await Signup.objects.filter(created_at__gte=since).acount() return JsonResponse({"signups_last_24h": count})

The app config imports subscribers in ready(), and a small main.py serves Django over Uvicorn and calls encore.start() in the ASGI lifespan, the same wrapper as in adding Pub/Sub to Django. The Procfile release phase becomes a migrate through encore exec, and the local secret is set once per machine:

~/web
$ python -c "import secrets; print(secrets.token_urlsafe(50))" | encore secret set DjangoSecretKey$ encore exec -- uv run python manage.py migrate$ encore runPOST /signups web trace f14aab47└ 201 42ms│ INFO:signups.subscribers:sending welcome email to ada@example.comPOST /signups 200 9ms web

encore run starts Postgres locally and delivers the welcome message, and the second signup with the same email returns 200 without publishing. Cron jobs do not run under encore run, so call POST /internal/daily-digest directly to test the digest. For production, set the secret with encore secret set --type production DjangoSecretKey, connect your AWS account and deploy, and Encore creates RDS, SNS and SQS, the cron schedule and a Fargate service with the IAM permissions between them in your AWS account. Each of your environments, staging and production for example, gets its own copies.

Once the declarations are in place, an agent migrating the next Celery task writes the subscriber, runs it locally and leaves the AWS side to the deploy. Cloud access sits with Encore's IAM role in your own account, not the agent.

Good to know

The digest route is a regular HTTP route, so it should do nothing harmful if called twice and return nothing sensitive. Encore does not run Django migrations itself, so keep running migrate before each deploy reaches traffic.

Try Encore →

Manual path: ECS on Fargate, RDS and Terraform

Done by hand, the same mapping is a set of Terraform resources and a container image. The web container runs Gunicorn as a non-root user:

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 python manage.py collectstatic --noinput \ && useradd --create-home --uid 10001 app USER app EXPOSE 8000 CMD ["gunicorn", "config.wsgi", "--bind", "0.0.0.0:8000", "--workers", "3"]

The task definition takes SECRET_KEY and the database password from Secrets Manager rather than plain environment variables:

resource "aws_ecs_task_definition" "web" { family = "web" requires_compatibilities = ["FARGATE"] network_mode = "awsvpc" cpu = 512 memory = 1024 execution_role_arn = aws_iam_role.task_execution.arn task_role_arn = aws_iam_role.web_task.arn container_definitions = jsonencode([{ name = "web" image = "${aws_ecr_repository.web.repository_url}:${var.image_tag}" portMappings = [{ containerPort = 8000 }] environment = [{ name = "DB_HOST", value = aws_db_instance.web.address }] secrets = [ { name = "SECRET_KEY", valueFrom = aws_secretsmanager_secret.django_key.arn }, { name = "DB_PASSWORD", valueFrom = "${aws_db_instance.web.master_user_secret[0].secret_arn}:password::" }, ] }]) }

Good to know

Around this sit the VPC and private subnets, an Application Load Balancer and target group, security groups between the load balancer, tasks and RDS, an ECR repository, a second service for the Celery worker with ElastiCache as its broker, and an EventBridge Scheduler rule that runs the digest as a task. The Heroku release phase becomes a one-off migrate task in the deploy pipeline. Django's deployment checklist is worth running through again, since settings such as ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS and SECURE_PROXY_SSL_HEADER change with the new load balancer.

Moving the data

The database move is the same whichever way the AWS side is built. Put the Heroku app in maintenance mode so nothing writes, take a dump in the custom format, and restore it into RDS with parallel jobs:

~/web
$ heroku maintenance:on -a my-django-app$ pg_dump "$(heroku config:get DATABASE_URL -a my-django-app)" -Fc --no-owner --no-acl -f web.dump$ pg_restore --no-owner --no-acl -j 4 -d "$RDS_URL" web.dump

--no-owner and --no-acl drop Heroku's role names, which do not exist on RDS, and the pg_restore documentation covers the -j option for restoring tables in parallel. After the restore, run migrate to confirm Django sees no pending migrations, compare row counts on the largest tables, switch DNS and turn maintenance off on the new side. AWS describes the same dump and restore approach in its guide to importing PostgreSQL data into RDS.

How to choose

  • Encore if you want the Heroku add-ons replaced by declarations in the Django code and created in your AWS account, with the whole app running locally.
  • ECS, RDS and Terraform by hand if your team already maintains Terraform and wants to own each resource definition.

Python support in Encore is in beta, and you can sign up for access to try it with your Django project.

Frequently asked questions

How do I migrate a Django app from Heroku to AWS?

Map each Heroku piece to an AWS service, run the app against a fresh RDS database, copy the data with pg_dump and pg_restore during a short write freeze, and switch DNS. With Encore, the Heroku add-ons become declarations in the Django code, such as postgres.Database("web"), secrets.SharedSecret[str]("DjangoSecretKey"), pubsub.Topic("signed-up") and cron.Job("daily-digest", path="/internal/daily-digest", schedule="0 7 * * *"), and Encore creates RDS, Secrets Manager, SNS and SQS and the schedule in your AWS account.

What replaces Heroku Postgres on AWS?

Amazon RDS for PostgreSQL or Aurora PostgreSQL. Create it in private subnets, restore a pg_dump from Heroku into it and point Django at it with sslmode=require.

What replaces Heroku Scheduler on AWS?

EventBridge Scheduler running an ECS task or calling an endpoint. With Encore, a cron.Job in the code calls an HTTP route on the schedule, and cron jobs run in deployed environments rather than under encore run.

How long does the database move from Heroku take?

For a few gigabytes, a pg_dump in the custom format and a parallel pg_restore usually fit in a short maintenance window. Larger databases need logical replication or a staged copy so the write freeze stays short.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders