← Blog

Move a Rails app from Heroku to Hetzner, step by step

Railyard

July 25, 2026 · 7 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 git push to somewhere else. A Rails app on Heroku needs a web server, a queue worker if it 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 Hetzner Cloud server, in Docker, ending with the same app running on infrastructure you control.

What you’ll need

  • A Hetzner Cloud server. A CX23 (2 vCPU, 4 GB memory, €5.99/month excluding VAT) matches what a lot of small Rails apps already run on a single Heroku dyno plus a hobby-dev or standard-0 Postgres add-on; move up to a CX33 (4 vCPU, 8 GB, €8.99/month) if the app runs a queue worker alongside the web process.
  • 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. This guide uses Rails 8.1, which runs on Ruby 4.0 or newer; the Dockerfile below pins Ruby 4.0.8.
  • 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 (web, worker, any other process types and their dyno counts); heroku addons shows what’s actually provisioned (Postgres plan, Redis, anything else billed separately) that needs an equivalent on the new server.

Step 2: Create the server

In the Hetzner Cloud Console, Add Server: Ubuntu 24.04, CX23, a location, your SSH key.

ssh root@your-server-ip
curl -fsSL https://get.docker.com | sh

Step 3: Write the Dockerfile

FROM ruby:4.0.8-slim-bookworm AS build

RUN apt-get update -qq \
    && apt-get install -y --no-install-recommends build-essential libpq-dev git pkg-config \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /rails

COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'development test' \
    && bundle install --jobs 4 --retry 3

COPY . .

RUN SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile

FROM ruby:4.0.8-slim-bookworm

RUN apt-get update -qq \
    && apt-get install -y --no-install-recommends libpq5 curl \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /rails
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /rails /rails

ENV RAILS_ENV=production
ENV RAILS_LOG_TO_STDOUT=1
ENV RAILS_SERVE_STATIC_FILES=1

EXPOSE 3000
CMD ["bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"]

SECRET_KEY_BASE_DUMMY=1 during asset precompilation avoids needing the app’s real secret at build time, the same workaround Rails’ own default Dockerfile (generated by rails new since 7.1) uses, since asset compilation doesn’t need the actual secret but will fail without one present.

Step 4: Write the Compose file

services:
  app:
    build: .
    restart: unless-stopped
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    expose:
      - "3000"

  worker:
    build: .
    restart: unless-stopped
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    command: ["bundle", "exec", "sidekiq"]

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

  redis:
    image: redis:7.4-alpine
    restart: unless-stopped

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy-data:/data
      - caddy-config:/config
    depends_on:
      - app

volumes:
  db-data:
  caddy-data:
  caddy-config:

Drop the worker service entirely if Step 1’s heroku ps output showed no worker dyno; a Rails app with no background job processing doesn’t need Sidekiq or Redis running at all.

Step 5: Map Heroku config vars to .env

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

POSTGRES_DB=app_production
POSTGRES_USER=app
POSTGRES_PASSWORD=change-this
DATABASE_URL=postgres://app:change-this@db:5432/app_production
REDIS_URL=redis://redis:6379/0
RAILS_MASTER_KEY=paste-from-config-or-config-master-key

RAILS_MASTER_KEY is the one Heroku almost certainly already had as RAILS_MASTER_KEY in its own config vars (Rails credentials are encrypted with it, and Heroku’s config vars are exactly where that key normally lives in production); copy it across exactly rather than regenerating it, or every encrypted credential in config/credentials.yml.enc becomes unreadable. Every other app-specific variable from heroku-config.txt (API keys, feature flags, anything ENV.fetch reads in your own code) belongs in this file too; DATABASE_URL and REDIS_URL specifically get new values pointing at the containers in Step 4 rather than Heroku’s own.

Step 6: 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@your-server-ip:/opt/app/
docker compose up -d db
docker compose exec -T db pg_restore -U app -d app_production --no-owner --role=app /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 7: Bring the full stack up and verify

docker compose up -d
docker compose exec app bin/rails db:migrate
docker compose ps

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

curl -H "Host: yourdomain.com" http://your-server-ip

Step 8: Write the Caddyfile and cut DNS over

yourdomain.com {
    reverse_proxy app:3000
}

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 server’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 6’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 and heroku-redis to provisioned Postgres and Redis 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 6 and 7 becomes a single import: point it at the live DATABASE_URL 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.

Troubleshooting

  • pg_restore: error: could not execute query ... role "..." does not exist: --no-owner was left off the restore command in Step 6; add it and the same objects restore successfully under the connecting role instead.
  • App boots but every page 500s on encrypted credentials: RAILS_MASTER_KEY doesn’t match what encrypted config/credentials.yml.enc, usually because it was regenerated instead of copied from Heroku’s own config vars in Step 5.
  • Background jobs never process: confirm the worker service is actually present in docker compose ps and that REDIS_URL matches between app and worker; a job enqueued against one Redis instance 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 yourdomain.com should match the new server’s IP consistently from multiple locations before assuming Caddy is at fault.
  • Data looks stale after cutover: this is Step 8’s write gap; if it matters for this app, redo Step 6’s dump and Step 7’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 server 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 uploads directly to local disk expecting them to persist, that was already broken on Heroku between dyno restarts; this migration is actually a good moment to move file 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 8 above surfaces them automatically.

Is a CX23 really comparable to a Heroku dyno? A standard-1x or standard-2x dyno’s actual CPU and memory are modest by comparison; a CX23’s 4 GB generally outperforms a single small dyon for the same or lower monthly cost, though the real test is your own app’s actual 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 server; that’s the actual rollback plan, not a backup file.

A Rails app that was costing a Heroku dyno and a Postgres add-on every month now runs on a Hetzner server at €5.99, with the same Postgres data, the same encrypted credentials, 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.