Deploy a Rails app to Hetzner
Follow this on your own server. Start free, no card needed
Hetzner’s cloud servers are some of the cheapest real compute you can rent by the month, but a cheap server doesn’t run your app by itself. You still have to provision it, install Docker, get Postgres running next to your app, put HTTPS in front of both, keep a backup somewhere that isn’t the server itself, and have a way to ship your next release without starting over each time. By the end of this guide you’ll have a Rails app, built from its own generated Dockerfile, running on a Hetzner server behind Caddy with a real certificate, backed by Postgres, with nightly backups and a short, repeatable way to deploy a new version.
What you’ll need
- A Hetzner Cloud account and a server. The cost-optimized CX23 (2 vCPU, 4 GB memory, 40 GB NVMe) lists at €5.99/month excluding VAT, which applies depending on where you’re billed. Check current pricing and availability in the Hetzner Cloud Console before ordering, since cloud lineups and prices do shift.
- A domain name you can add a DNS record to.
- An SSH key pair on your machine (
ssh-keygen -t ed25519if you don’t already have one). - A Rails app, or a new one: this guide builds one from scratch with
rails new, so you can follow along even without an existing app. - About fifteen minutes for the server and DNS to settle, mostly spent waiting rather than typing.
Step 1: Create the server and note its IP
In the Hetzner Cloud Console, open (or create) a project, then choose Add Server. Pick a location: Nuremberg, Falkenstein or Helsinki keep your data in the EU, while Ashburn, Hillsboro or Singapore are also on offer if your users are elsewhere. Choose Ubuntu 24.04 as the image, then under server type pick the Shared vCPU CX23 (2 vCPU, 4 GB memory, 40 GB NVMe). Under SSH keys, add the public key from your machine instead of relying on a generated password, then create the server.
Once it boots, the console shows its public IPv4 address. Connect to confirm access:
ssh root@YOUR_SERVER_IP
You should see the Ubuntu welcome banner and land at a root shell. If the connection is refused, double check the SSH key attached to the server matches the private key on your machine.
Step 2: Point your domain at the server
Add an A record at your DNS provider for the hostname you want to use, pointing at the server’s IP:
Type: A
Name: myapp (or @ for the bare domain)
Value: YOUR_SERVER_IP
TTL: 300
DNS propagation can take a few minutes. Check it from your own machine before moving on, since Caddy needs this to already resolve correctly in a later step:
dig +short myapp.example.com
You should see your server’s IP printed back. If you get nothing, wait a few minutes and try again before continuing.
Step 3: Install Docker on the server
Rails’s generated Dockerfile needs Docker Engine and the Compose plugin on the server. Docker’s own install script handles both on a fresh Ubuntu box:
apt-get update
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
systemctl enable --now docker
Hetzner doesn’t put a firewall in front of a new server by default, so lock the host down to just what you need with ufw:
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable
Check the install:
docker --version
docker compose version
You should see a Docker version and a Compose version printed, not a “command not found” error.
Step 4: Generate the Rails app with a Dockerfile and Postgres
On your own machine, generate a new app configured for Postgres instead of the SQLite default:
gem install rails -v 8.1.4
rails new myapp --database=postgresql
cd myapp
rails new creates a Dockerfile by default, listed among the generated files alongside .dockerignore, and it’s tuned for production: it installs your gems and precompiles assets in one build stage, then copies only what’s needed into a slim runtime image that runs as a non-root user. Rails 8 also includes Thruster, a small HTTP proxy in front of Puma that handles HTTP/2, compression and asset serving, which is why the generated image exposes port 80 internally and starts with ./bin/thrust ./bin/rails server instead of starting Puma directly. You won’t reach that port from the internet directly, Caddy sits in front of it in Step 7.
The Dockerfile’s entrypoint script also runs ./bin/rails db:prepare automatically, every time the container’s command is a Rails server, before the server actually starts. That means you don’t need a separate migration step later in this guide: the first boot creates the database, and every later boot applies whatever migrations shipped in that release.
Because you passed --database=postgresql, config/database.yml is set up for the pg gem instead of SQLite. If you’re on Rails 8, the same file also wires up separate cache, queue and cable database entries for Solid Cache, Solid Queue and Solid Cable, Rails’s own default background job and caching backends. All four, the primary database plus these three, read from the same DATABASE_URL and differ only in the database name each one asks for (myapp_production, myapp_production_cache, and so on), so one Postgres container and one password cover every one of them; db:prepare creates all four the first time it runs.
Verify the files are there:
ls Dockerfile .dockerignore config/database.yml
You should see all three paths printed with no error.
Step 5: Pull your secrets off the app and onto the server
Rails encrypts config/credentials.yml.enc with config/master.key, a file that’s gitignored on purpose and never shipped to the server. Read its value once, on your own machine:
cat config/master.key
On the server, create a directory for the app and an .env file with that key and a database password you generate yourself:
mkdir -p /opt/myapp
cat > /opt/myapp/.env <<'EOF'
RAILS_MASTER_KEY=paste-the-value-from-config-master-key-here
POSTGRES_PASSWORD=a-long-random-password
EOF
chmod 600 /opt/myapp/.env
Check it:
cat /opt/myapp/.env
You should see both variables with real values, not placeholders, and the file should not be world readable (-rw------- from ls -l).
Step 6: Write the Docker Compose file
Copy your app to the server (scp, rsync, or a git clone of your repository into /opt/myapp), then add compose.yml next to it:
services:
app:
build: .
image: myapp:latest
restart: unless-stopped
env_file: .env
environment:
RAILS_ENV: production
DATABASE_URL: postgres://myapp:${POSTGRES_PASSWORD}@db:5432/myapp_production
depends_on:
db:
condition: service_healthy
volumes:
- storage:/rails/storage
expose:
- "80"
db:
image: postgres:16.4-alpine
restart: unless-stopped
environment:
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: myapp_production
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 5s
retries: 10
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:
storage:
caddy_data:
caddy_config:
The app service reaches db by its service name, not localhost, since Compose puts both containers on the same private network. depends_on: condition: service_healthy keeps the app from starting before Postgres can actually accept connections, not just before its container exists. Named volumes keep your database and Active Storage files on disk across restarts and rebuilds; only caddy publishes ports to the host, so Postgres is never reachable from outside this server at all.
Check the file parses cleanly before going further:
docker compose config
You should see the fully resolved configuration printed with no error lines.
Step 7: Add Caddy as a reverse proxy with HTTPS
Add a Caddyfile next to compose.yml:
myapp.example.com {
reverse_proxy app:80
}
Replace myapp.example.com with the domain you pointed at the server in Step 2. Caddy does two things on its own from this one block: it proxies every request to the app container’s port 80, and it requests a free TLS certificate from Let’s Encrypt for that exact hostname, renewing it automatically before it expires. This only works if the domain already resolves to this server, which is why DNS came before Caddy in this guide.
Step 8: Boot the stack
From /opt/myapp:
docker compose up -d --build
Because of the entrypoint behavior from Step 4, this single command builds the image, starts Postgres, waits for it to report healthy, then starts the app, which creates the database and runs migrations on its own before Puma (behind Thruster) actually starts.
Check the stack:
docker compose ps
curl -I https://myapp.example.com
You should see all three services as Up (and db as healthy), and the curl should return HTTP/2 200 with no certificate warning.
Step 9: Back up Postgres
A dump taken outside the data volume is what actually protects you if the server disappears. Add a backup script:
cat > /opt/myapp/backup.sh <<'EOF'
#!/bin/sh
cd /opt/myapp
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
docker compose exec -T db pg_dump -U myapp myapp_production | gzip > /opt/myapp/backups/$TIMESTAMP.sql.gz
find /opt/myapp/backups -name '*.sql.gz' -mtime +14 -delete
EOF
mkdir -p /opt/myapp/backups
chmod +x /opt/myapp/backup.sh
Schedule it with cron, daily at 3am:
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/myapp/backup.sh") | crontab -
Run it once by hand and check it worked:
/opt/myapp/backup.sh
ls -lh /opt/myapp/backups
You should see a .sql.gz file with today’s timestamp and a non-zero size. For anything beyond a personal project, copy these files off the server too, to another machine or an object store, so losing the server doesn’t also mean losing the backup.
Step 10: Ship an upgrade
Every later change follows the same shape: get the new code onto the server, rebuild the image, replace the running container.
git pull
docker compose build app
docker compose up -d app
The entrypoint script runs db:prepare again on this new container before the server starts, so any migration that shipped with this release is applied automatically. docker compose up -d app only recreates the app container, not db or caddy, so Postgres keeps running and Caddy’s certificate state is untouched. There’s a short gap, a second or two, while the old container stops and the new one starts, since this manual setup runs a single app container. Check it landed:
docker compose logs --tail=50 app
curl -I https://myapp.example.com
You should see your app boot cleanly in the logs and the curl still returning 200 on the new version.
The shortcut: do it with Railyard
Everything above, the server, Docker, Postgres, Caddy, backups, a repeatable release, is also what Railyard automates for a Rails app on Hetzner, without you writing any of the files above by hand.
Paste your repository’s URL into https://app.railyard.run/deploy?repo= and pick Hetzner as the provider. The first time, you’ll generate a token in the Hetzner Cloud Console (open your project, then Security, then API tokens, and generate one with Read & Write permission) so Railyard can provision the server on your behalf. For a Rails app on Hetzner, Railyard’s default is a 2 vCPU / 4 GB server, the same size used manually above, and you can pick any Hetzner region, including Nuremberg, Falkenstein or Helsinki if EU data residency matters to you.
From there, Railyard reads your app the same way this guide did by hand: it sees pg in your Gemfile.lock and provisions a managed Postgres database instead of SQLite, and it runs bin/rails db:prepare before every release the same way the Dockerfile’s own entrypoint did manually in Step 8. If you’re on Rails 8, it gives the cache, queue and cable databases their own connection strings instead of you reasoning through that yourself, the way Step 4 explained. Active Storage on disk gets a persistent volume automatically, matching the storage volume from Step 6, and SECRET_KEY_BASE is generated once and kept, so you never manage it by hand. If your Gemfile.lock includes Sidekiq, Solid Queue, GoodJob, Resque or Delayed Job, Railyard offers it as a worker process alongside your web process, no separate compose service to write. HTTPS and the reverse proxy from Step 7 are handled the same way, pointed at your domain once you add it.
Read more in deploying from a repository, databases and backups, the one-click deploy button, getting started, the Rails on Hetzner reference guide, and hosting cost comparisons if you want to see how the server-only cost here compares to a managed path. 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.
Troubleshooting
Port already in use. If docker compose up fails with something binding to port 80 or 443, Ubuntu’s default Apache or Nginx may already be installed and running. Check with ss -tlnp | grep -E ':80|:443' and stop or remove whichever service owns them before Caddy tries to bind.
Wrong RAILS_MASTER_KEY or credentials. An error like ActiveSupport::MessageEncryptor::InvalidMessage on boot almost always means the RAILS_MASTER_KEY in /opt/myapp/.env doesn’t match the key that encrypted config/credentials.yml.enc. Re-run cat config/master.key locally and compare it character for character with what’s in .env on the server.
Postgres connection refused. If the app logs show could not connect to server: Connection refused, check that DATABASE_URL uses db as the hostname, not localhost or 127.0.0.1. Inside Compose’s network, db is the only address that resolves to the Postgres container; localhost means the app container itself, which has no database running in it.
HTTPS not issuing. If docker compose logs caddy shows repeated certificate errors or NXDOMAIN, your DNS record from Step 2 either hasn’t propagated yet or is pointing at the wrong IP. Re-run dig +short myapp.example.com, confirm it matches the server, then restart Caddy with docker compose restart caddy once it does.
Out of memory during asset precompile. A CX23’s 4 GB of memory is usually enough, but docker compose build can still get killed partway through asset compilation on a larger app, especially if it’s building and running at the same time. Add a 2 GB swap file before building: fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile, then make it permanent by adding it to /etc/fstab.
FAQ
Is a CX23 enough for production? For a small to mid-sized Rails app with modest traffic, yes, since Puma, Postgres and Caddy together fit comfortably in 4 GB, and the swap fix above covers the one case, large asset builds, where memory gets tight.
Can I skip Postgres and keep SQLite? You can; SQLite works fine on a single server as long as you mount its database file on a persistent volume so it survives container restarts, but Postgres gives you a cleaner path if you ever add a second app server.
How do I add Redis or Sidekiq? Add a redis service to compose.yml using the official redis image with its own named volume, add REDIS_URL to app’s environment pointing at redis://redis:6379/0, and add a worker service that runs bundle exec sidekiq against the same image instead of the Rails server command.
Does this survive a server reboot? Yes, restart: unless-stopped on every service means Docker brings the whole stack back up automatically after the server restarts, as long as Docker’s own systemd service is enabled, which the install script in Step 3 does by default.
What are Solid Cache, Solid Queue and Solid Cable? They’re Rails 8’s default backends for caching, background jobs and Action Cable, all backed by a database instead of a separate service like Redis. That’s why a fresh Rails 8 app needs four database names instead of one, and why this guide’s single Postgres container and one password cover all of them.
That’s a full production setup: a server, Docker Compose running your app and Postgres, Caddy handling HTTPS, a nightly backup, and a two-command release. Keep this compose.yml and Caddyfile in your repository alongside the app, and every future deploy is the same git pull, build, restart you just ran in Step 10.
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.