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:
Procfile.DATABASE_URL.SECRET_KEY.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.

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:
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.
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.
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::" },
]
}])
}
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.
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:
--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.
Python support in Encore is in beta, and you can sign up for access to try it with your Django project.
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.
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.
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.
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.