← Blog

Move a Django app from Heroku to DigitalOcean

Railyard

August 13, 2026 · 9 min read

Follow this on your own server. Start free, no card needed

Heroku dynos, config vars and heroku pg:backups hide a real amount of infrastructure work behind a CLI, which is exactly why leaving is more involved than pushing to somewhere else. A Django app on Heroku needs a server that stays up, a process manager running gunicorn, a Celery worker if the app has one, a Postgres database with the actual data in it, every config var mapped to a real environment, and a domain cut over without a gap where neither server has current data. This guide does all of that against a DigitalOcean droplet, in Docker, ending with the same app running on infrastructure you control.

What you’ll need

  • A DigitalOcean droplet. The Basic plan at 1 vCPU / 2 GB RAM runs Postgres, gunicorn and Caddy together comfortably and currently costs $12/month; the 1 GB plan ($6/month) is too tight once Postgres is also running alongside everything else.
  • The Heroku CLI, already authenticated, to pull config vars and a database dump.
  • A domain currently pointed at Heroku, with access to change its DNS.
  • The app’s source in a Git repository, with a requirements.txt Heroku already builds from. This guide uses Django 6.1.2, gunicorn 26.2.0 as the WSGI server, and psycopg2-binary 2.9.13 to talk to Postgres.
  • Docker and the Compose plugin, either locally to test the build first or on the server.

Step 1: Inventory what’s actually running on Heroku

Heroku’s API doesn’t expose your repository directly, so this step is manual, and skipping it is the easiest way to miss something on the new server. Pull the three things you need:

heroku config -a your-app-name > heroku-config.txt
heroku ps -a your-app-name
heroku addons -a your-app-name

heroku ps shows your current formation. For a Django app that’s typically a web dyno running gunicorn, and maybe a worker dyno running Celery if the app processes background jobs. heroku addons shows what’s actually provisioned, almost always heroku-postgresql on some plan, which needs an equivalent Postgres container on the new server.

Step 2: Create the droplet

From the DigitalOcean dashboard, create a new droplet: choose the Ubuntu 24.04 LTS image, the 2 GB Basic plan, a region close to your users, and your SSH key. Once it’s running, note its public IP address and point your domain’s A record at it:

myproject.example.com.  A  203.0.113.10

DNS propagation can take a few minutes to a few hours; you can continue setting up the server in the meantime. SSH in as root and install Docker:

ssh root@203.0.113.10
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin

Check it worked:

docker compose version

Step 3: Prepare the Django app for production

Heroku’s Procfile and ephemeral dynos hide a few settings Django needs set explicitly on a server you manage yourself. In settings.py:

import os

DEBUG = False
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ["POSTGRES_DB"],
        "USER": os.environ["POSTGRES_USER"],
        "PASSWORD": os.environ["POSTGRES_PASSWORD"],
        "HOST": "db",
        "PORT": "5432",
    }
}

STATIC_URL = "/static/"
STATIC_ROOT = "/app/staticfiles"

If the app currently reads DATABASE_URL as a single string through django-environ or dj-database-url on Heroku, that keeps working; the environment variables above are just the plainest version once the database is a container named db instead of Heroku’s own Postgres host. Confirm the exact versions pinned in requirements.txt match what this guide assumes:

django==6.1.2
gunicorn==26.2.0
psycopg2-binary==2.9.13

Step 4: Write the Dockerfile

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

CMD ["sh", "-c", "python manage.py migrate --no-input && gunicorn myproject.wsgi:application --bind 0.0.0.0:8000 --workers 3"]

Replace myproject.wsgi with your actual project package. Running migrate before gunicorn starts means every deploy applies pending migrations automatically, which replaces whatever release step Heroku was running (a release: python manage.py migrate line in the Procfile, most likely). Three gunicorn workers is a reasonable starting point for a 2 GB droplet; watch memory under load and adjust.

Step 5: Write docker-compose.yml

services:
  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: myproject
      POSTGRES_USER: myproject
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myproject"]
      interval: 5s
      timeout: 5s
      retries: 5

  web:
    build: .
    restart: unless-stopped
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - static_files:/app/staticfiles
    expose:
      - "8000"

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - static_files:/srv/static
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - web

volumes:
  postgres_data:
  static_files:
  caddy_data:
  caddy_config:

Add a worker service built from the same image, running celery -A myproject worker --loglevel=info, if Step 1’s heroku ps output showed a worker dyno. It needs the same .env file and a broker (Redis or RabbitMQ) as another container the same way Postgres is one here. Only Caddy publishes ports to the outside world; web and db stay reachable only from containers on the same Compose network.

Step 6: Map Heroku config vars to .env

Open heroku-config.txt from Step 1 and translate it, variable by variable, into the server’s .env, kept out of version control:

POSTGRES_DB=myproject
POSTGRES_USER=myproject
POSTGRES_PASSWORD=change-this
DJANGO_ALLOWED_HOSTS=myproject.example.com
SECRET_KEY=paste-from-heroku-config-vars

SECRET_KEY is the one variable here that needs special care. Django’s SECRET_KEY signs sessions and other signed cookies; it isn’t an encryption key for stored data the way Rails’ RAILS_MASTER_KEY is. That distinction matters for what happens if you get it wrong: regenerate SECRET_KEY instead of copying the value Heroku already had in its config vars, and nothing in the database becomes unreadable, but every logged-in user’s session and every signed cookie the app issued is silently invalidated the moment the new value takes effect, logging everyone out at once. Copy it across exactly from heroku-config.txt rather than generating a fresh one, unless forcing every user to log in again is actually an acceptable cost for this cutover. Every other app-specific variable from heroku-config.txt (API keys, feature flags, anything os.environ.get reads in your own code) belongs in this file too; DATABASE_URL, if the app uses it as a single string, gets a new value pointing at the db container rather than Heroku’s own Postgres host.

Step 7: Migrate the database

With the app not yet running on the new server, pull a fresh dump from Heroku:

heroku pg:backups:capture -a your-app-name
heroku pg:backups:download -a your-app-name -o latest.dump

Copy it to the new server and restore it into the freshly created (empty) Postgres container:

scp latest.dump root@203.0.113.10:/opt/app/
docker compose up -d db
docker compose exec -T db pg_restore -U myproject -d myproject --no-owner --role=myproject /tmp/latest.dump

--no-owner matters specifically here: Heroku’s dump is owned by a role name that doesn’t exist on your new Postgres container, and without --no-owner, the restore fails partway through on every object it can’t reassign.

Step 8: Bring the full stack up and verify

docker compose up -d --build
docker compose ps

You should see web (and worker, if present), db and caddy all running. Before cutting DNS over, test against the droplet’s raw IP with a Host header to confirm the app actually serves real pages with real data:

curl -H "Host: myproject.example.com" http://203.0.113.10

A 400 Bad Request here almost always means DJANGO_ALLOWED_HOSTS in .env doesn’t match the domain in the Host header; fix it and redeploy web before moving on.

Step 9: Write the Caddyfile and cut DNS over

myproject.example.com {
	encode gzip

	handle_path /static/* {
		root * /srv/static
		file_server
	}

	reverse_proxy web:8000
}

Static files under /static/ are served directly by Caddy from the shared volume; everything else is proxied to gunicorn. Lower your domain’s DNS TTL a day or two before the cutover if it’s currently high, then point the A record at the new droplet’s IP. Heroku keeps serving the old data until DNS propagates, which is also the real risk window: any write that happens on Heroku after Step 7’s dump but before traffic fully moves to the new server is a row this migration doesn’t have. A short maintenance window, or a final incremental dump taken right before the DNS switch, closes that gap for anything that can’t tolerate losing a few minutes of writes.

The shortcut: do it with Railyard

Pasting a Heroku app’s name (or connecting Heroku directly) into import from another platform reads the same three things Step 1 pulled by hand: config vars, formation (web and worker process types with their counts), and add-ons, mapping heroku-postgresql to a provisioned Postgres database automatically. Heroku doesn’t expose your repository through its API either way, so you still paste the Git URL you push from, the one honest manual step Railyard can’t skip either. The database migration in Steps 7 and 8 becomes a single import: point it at the live database or upload a dump, and it backs up what’s there first, restores, then runs migrations automatically, the same pg_restore --no-owner behavior under the hood without the manual flag to remember. See database imports for the exact flow, and deploy Django to DigitalOcean for how Railyard detects a Django app from requirements.txt and wires up gunicorn, migrations and Postgres without the Dockerfile and Compose file this guide writes by hand.

Railyard’s Free plan covers one server plus a database server and three apps with no card required; Pro is $19/month per team, and Team is $49/month per team for larger projects. See migrating from Heroku for the full picture of what moves and what doesn’t.

Troubleshooting

  • pg_restore: error: could not execute query ... role "..." does not exist: --no-owner was left off the restore command in Step 7; add it and the same objects restore successfully under the connecting role instead.
  • “Bad Request (400)” from Django: DJANGO_ALLOWED_HOSTS doesn’t match the domain you’re visiting. Confirm it in .env, redeploy with docker compose up -d web, and check docker compose logs web.
  • Every user gets logged out after the cutover: SECRET_KEY in .env doesn’t match the value Heroku’s config vars had, usually because it was generated fresh in Step 6 instead of copied across; every session and signed cookie was already invalidated by the mismatch, and copying the original value doesn’t retroactively fix sessions created after the wrong key went live.
  • Background jobs never process: confirm the worker service is actually present in docker compose ps and that its broker URL matches the web service’s; a job enqueued against one broker and a worker polling another never meets.
  • HTTPS never issues after the DNS cutover: DNS propagation can take longer than expected depending on the previous TTL; dig +short myproject.example.com should match the new droplet’s IP consistently from multiple locations before assuming Caddy is at fault.
  • Port 80 or 443 already in use: another web server, often an nginx installed by a DigitalOcean one-click image, is already bound to it. Stop and disable it: systemctl stop nginx && systemctl disable nginx.
  • Data looks stale after cutover: this is Step 9’s write gap; if it matters for this app, redo Step 7’s dump and Step 8’s restore immediately before flipping DNS, with writes paused on Heroku in between.

FAQ

Can I do this with zero downtime? Not with a single dump-and-restore the way this guide does it; a logical replication setup from Heroku Postgres to the new droplet removes the write gap entirely, but it’s meaningfully more setup than most small apps need for the few minutes of risk a maintenance window covers.

What about Heroku’s ephemeral filesystem, does anything depend on it? If the app writes user uploads directly to local disk expecting them to persist, that was already broken on Heroku between dyno restarts; this migration is a good moment to move media storage to an object store or a Docker volume if it wasn’t already.

Do I need to migrate scheduled jobs too? Heroku Scheduler entries aren’t covered by heroku config or heroku ps; check the Heroku Scheduler add-on’s dashboard separately and recreate them as cron entries or a dedicated Railyard cron schedule, since nothing in Steps 1 through 9 above surfaces them automatically.

Is a 2 GB droplet really comparable to a Heroku dyno? A standard-1x or standard-2x dyno’s actual CPU and memory are modest by comparison; a 2 GB droplet generally runs Postgres, gunicorn and Caddy together for less than the equivalent dyno plus heroku-postgresql add-on cost, though the real test is your own app’s memory footprint under real traffic, not a spec sheet.

What if the migration goes wrong after DNS has already switched? Keep the Heroku app running (not scaled to zero) for at least a few days after cutover specifically so DNS can be pointed back at it immediately if something’s wrong on the new droplet; that’s the actual rollback plan, not a backup file.

A Django app that was costing a Heroku dyno and a heroku-postgresql add-on every month now runs on a DigitalOcean droplet at $12, with the same Postgres data, the same signed sessions, and the same background jobs, just running somewhere you provisioned and fully understand.

Why people pick Railyard for this

  • Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
  • One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
  • One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
  • Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
  • Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.

Skip the manual migration.

Paste your Heroku app's name into Railyard and it reads your config vars, formation and add-ons, then imports the database directly.

Free plan, no card, and 14 days of Pro for every new team. One flat price per team.