← Blog

Move from Render to your own server

Railyard

April 24, 2026 · 11 min read

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

Render’s pricing climbs fast once a web service needs more than the smallest instance, and its managed Postgres adds its own line item on top of that. Some teams hit the free tier’s spin-down behavior and still don’t want to pay for a bigger plan just to keep a side project awake. Others just want a server they control, where no platform decides what instance sizes exist or what happens if a card on file expires. This guide moves a Render web service, its background worker if it has one, and its managed Postgres database onto a server you own. By the end you’ll have the same app running from its own Dockerfile on a server you provisioned, its database restored with the same rows and schema, HTTPS issued automatically, and a backup job keeping copies of the data going forward.

What you’ll need

  • A new server. We’ll use a Hetzner CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe), listed at €5.99 a month excluding VAT. That’s enough room to run the app container, a worker process if you have one, Postgres, and a Caddy reverse proxy on the same box, which covers what a typical Render web service plus its database needs. If you’d rather compare providers first, Railyard keeps a hosting costs guide with current numbers across several of them.
  • A domain name you can point at the new server. An A record change takes a few minutes to propagate, and HTTPS won’t issue until it does.
  • A Render API key. Create one from Account Settings in the Render dashboard (dashboard.render.com/u/settings), then “Add API Key”. Render issues keys with access to everything in your account rather than a scoped, read-only option, so store it the way you’d store any other credential and delete it once the migration is done.
  • Your app’s repository, with the Dockerfile Render was already building from. If Render was building your service from a non-Docker runtime instead, you’ll need to add a Dockerfile now, since nothing on the new server builds your app for you otherwise.

Railyard also keeps a dedicated Render migration guide with a feature-by-feature comparison, if you want to see what’s different before you start moving anything.

Step 1: Pull your current configuration from Render’s API

Before touching the new server, get an accurate record of what Render is actually running, rather than relying on memory or an old README. Render’s API lets you read back everything your service is configured with.

export RENDER_API_KEY="rnd_your_key_here"

curl -s -H "Authorization: Bearer $RENDER_API_KEY" \
  "https://api.render.com/v1/services?limit=100" \
  | jq '.[].service | {id, name, type, repo}'

That lists every service in your account with its id. Pick the one you’re moving and pull its full detail, environment variables and custom domains:

SERVICE_ID="srv-xxxxxxxxxxxxxxxxxxxx"

curl -s -H "Authorization: Bearer $RENDER_API_KEY" \
  "https://api.render.com/v1/services/$SERVICE_ID" \
  | jq '{name, type, repo, branch, startCommand: .serviceDetails.envSpecificDetails.startCommand, ports: .serviceDetails.openPorts}'

curl -s -H "Authorization: Bearer $RENDER_API_KEY" \
  "https://api.render.com/v1/services/$SERVICE_ID/env-vars?limit=100" \
  | jq '.[].envVar'

curl -s -H "Authorization: Bearer $RENDER_API_KEY" \
  "https://api.render.com/v1/services/$SERVICE_ID/custom-domains?limit=100" \
  | jq '.[].customDomain.name'

You should see your service’s repository URL, branch, start command and port; the full list of environment variables, including DATABASE_URL if you’re using Render’s managed Postgres; and any custom domain names attached to the service. Write all of it down, you’ll need every piece in the steps below.

One thing this API will never hand you: the contents of any secret files attached to the service. Render has no endpoint for those, so open each one in the dashboard now and copy its contents by hand before you lose access to the account.

Step 2: Provision the new server

Create the server (a Hetzner CX23 or similar, running Ubuntu), add your SSH key during creation, and note its public IP. Then connect and bring packages up to date.

ssh root@<server-ip>
apt-get update && apt-get upgrade -y

You should see the shell prompt return with no pending upgrades left.

Step 3: Install Docker

curl -fsSL https://get.docker.com | sh

Check it worked:

docker --version
docker compose version

Both commands should print a version number. Docker’s install script ships Compose as a plugin, so there’s no separate install step for it.

Step 4: Bring your repository and Dockerfile onto the server

git clone <your-repo-url> /opt/app
cd /opt/app

If Render was building this service from a Dockerfile already, you’re set. If it was using a buildpack-style runtime (Ruby, Node, and so on), add a Dockerfile to the repo now, since that buildpack doesn’t exist outside of Render.

Create an environment file next to the repo with the values you pulled in Step 1, plus a password for the new database:

# /opt/app/.env
POSTGRES_PASSWORD=choose-a-strong-password-here

Any other variables Render listed for you go in this same file, and get referenced from docker-compose.yml in the next steps.

Step 5: Get the database out of Render

Open the database’s Info page in Render’s dashboard and find the external connection string shown there (it works from outside Render’s own network, unlike the internal one services on Render use to reach it). Use it to take a dump:

pg_dump --format=custom --no-owner --no-acl \
  "postgresql://user:password@dpg-xxxxxxxx.oregon-postgres.render.com/dbname" \
  -f render_dump.pgsql

Use a local pg_dump matching the Postgres major version you’ll run on the new server (16, to match the image in the next step). You should see a render_dump.pgsql file appear, sized roughly in proportion to your data. Confirm it’s readable before moving on:

pg_restore --list render_dump.pgsql | head

You should see a list of your tables, sequences and indexes.

Step 6: Start Postgres on the new server and restore

Write the Compose file for the full stack now, so Step 6 only needs the database service running:

# /opt/app/docker-compose.yml
services:
  app:
    build: .
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app_production
      RAILS_ENV: production
    depends_on:
      - db
    expose:
      - "3000"

  worker:
    build: .
    command: bundle exec sidekiq
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app_production
      RAILS_ENV: production
    depends_on:
      - db

  db:
    image: postgres:16.4-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: app_production
    volumes:
      - pgdata:/var/lib/postgresql/data

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

volumes:
  pgdata:
  caddy_data:

Drop the worker service if Render never ran one for this app. Bring up only the database for now:

docker compose up -d db

Copy the dump in and restore it:

docker cp render_dump.pgsql $(docker compose ps -q db):/tmp/render_dump.pgsql
docker compose exec db pg_restore --no-owner --no-acl \
  -U app -d app_production /tmp/render_dump.pgsql

Check it landed correctly:

docker compose exec db psql -U app -d app_production -c '\dt'

You should see the same table names you saw in pg_restore --list back in Step 5.

Step 7: Bring up the rest of the stack

docker compose up -d --build

Check:

docker compose ps

You should see app, worker (if present), db and caddy all listed as running.

Step 8: Point DNS and let Caddy issue HTTPS

Add an A record for your domain pointing at the server’s IP. Write the Caddyfile:

# /opt/app/Caddyfile
app.example.com {
    reverse_proxy app:3000
}

Replace app.example.com with each domain you listed in Step 1, one block per domain if you have more than one. Reload Caddy to pick up the file:

docker compose restart caddy

You should see a valid certificate issue within a few seconds of the DNS record resolving: curl -I https://app.example.com returns an HTTP/2 200 with no certificate warning.

Step 9: Set up backups

Add a daily cron job on the server that dumps the database and keeps a rolling window of copies:

mkdir -p /opt/backups
crontab -e
0 3 * * * docker compose -f /opt/app/docker-compose.yml exec -T db \
  pg_dump -U app app_production | gzip > /opt/backups/app_$(date +\%F).sql.gz \
  && find /opt/backups -name '*.sql.gz' -mtime +14 -delete

Run the command by hand once to confirm it before waiting for 3am:

docker compose exec -T db pg_dump -U app app_production | gzip > /opt/backups/app_test.sql.gz
ls -lh /opt/backups

You should see a .sql.gz file with a non-zero size.

Step 10: Cut over, with Render still running as your fallback

Keep the Render service and its database untouched through all of the steps above. Before moving DNS for real, confirm the new server serves the app correctly with a direct request:

curl -H "Host: app.example.com" http://<server-ip>

Once that, a login, and a write path all check out against the restored database, move the domain’s DNS to the new server (if you hadn’t already in Step 8) and give it time to propagate fully. Only after you’ve confirmed traffic is reaching the new server should you pause or delete the Render service.

Step 11: Keep the app updated

cd /opt/app
git pull
docker compose up -d --build

Compose only rebuilds and restarts the services whose image actually changed, so a small code change doesn’t take down the database or Caddy. For an app that can’t tolerate even a few seconds of restart time, you’d want a second app container and a load balancer swap instead, which is more than this single-server setup covers.

The shortcut: do it with Railyard

Everything above (reading a Render service’s configuration through its API, pulling an external database connection string, dumping and restoring it) is exactly what Railyard’s Applications → Import does for you. Point it at a Render API token and it reads your apps, shows a plan per app before you confirm anything, and creates the app on the server you pick. For a Render service specifically, it brings over the repository and branch, environment variables, start command and custom domains; a background worker comes across as a worker process with no web URL, the same distinction you’d make by hand in Step 6. Render’s API still doesn’t expose secret file contents to anyone, including Railyard, so that’s the one piece you add yourself after the import, the same as in Step 1 above. Render import is currently in beta, so read the plan screen before confirming, the same way you’d double check the values in Step 1.

The database side of this is also available on its own, outside of a full app import. Railyard’s Import data accepts either a live source database URL or an uploaded dump file, backs up the destination database first every time so a failed or cancelled import restores automatically, and can run your app’s migrations right after if you choose “deploy after import.” It covers dumps up to 5 GiB by default.

Full details are in Import from another platform and Databases and backups. Railyard’s Free plan covers a move this size without a card: one server plus a database server and three apps.

Troubleshooting

A curl call against the Render API returns 401 or 403. Render API keys aren’t scoped, there’s no read-only or limited option, so a rejected request almost always means the key was mistyped, revoked, or regenerated after you copied it, not a missing permission. Generate a fresh one from Account Settings and try again.

The background worker won’t start, or starts listening on a port it shouldn’t. A Render background worker never had a public URL to begin with, so don’t give its Compose service an expose line or a block in the Caddyfile. If it’s crashing instead, check that its start command is the worker’s command (bundle exec sidekiq, a queue consumer, and so on), not the web service’s command copied over by mistake.

The app boots but immediately errors on the port it’s listening on. The start command you copied in Step 1 was tuned for Render’s environment, where Render injects $PORT itself. Your new container won’t set that variable unless you do, so either set PORT explicitly in the Compose environment block to match what you expose, or edit the start command to bind to a fixed port.

Caddy never issues a certificate. Run docker compose logs caddy and look for repeated authorization failures or timeouts. That almost always means the domain’s A record isn’t resolving to this server yet. Compare dig +short app.example.com against the server’s own IP (curl -4 icanhazip.com run on the server) and wait for them to match before expecting HTTPS to work.

The app fails to boot complaining about a missing file. If the path in the error matches somewhere Render stored a secret file for this service, that’s the one thing the Render API never gives you. Open the file in Render’s dashboard, copy its contents, and either recreate it as a file your container mounts or reads, or convert its contents into an environment variable if the app can read it that way instead.

FAQ

Does this cause downtime? Not for the database: both platforms’ Postgres stay live and readable right up to Step 10, since you’re reading from Render’s copy and writing to your own. The app itself has a short window between the DNS change propagating and the new server fully warmed up and serving the same data, typically well under a minute if you’ve already confirmed the direct IP request in Step 10 works.

What about Render’s free tier spin-down behavior? That’s specific to Render’s free web services, which sleep after a period of inactivity and take a few seconds to wake on the next request. A container on your own server runs continuously once it’s started, there’s no equivalent sleep state to design around.

Can I keep Render as a fallback during the move? Yes, that’s what Step 10 is built around: leave the Render service and database running untouched until you’ve confirmed logins, writes and any background jobs all work correctly against the new server, then move DNS and only pause or cancel Render afterward.

Do I need to recreate my custom domains myself? You already have the full list from Step 1. Point each domain’s DNS at the new server and add a matching block to the Caddyfile for each one; Caddy requests and renews HTTPS for each domain the same way.

Does Railyard’s import bring over secret files automatically? No. That’s the one piece neither the manual path nor the import can automate, since Render’s API doesn’t expose secret file contents to anything outside Render, including Railyard. You add those once, by hand, after the import finishes, the same as you would in Step 1.

Moving off Render doesn’t mean giving up managed Postgres or automatic HTTPS. It means running the same pieces (Docker Compose, Caddy, Postgres) yourself, on a server whose price you fixed before you signed up for it. Whether you work through the steps above by hand or let Applications → Import handle the repository, variables and database copying, you end up with an app you can see, log into over SSH, and back up without asking a dashboard’s permission first.

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 steps

Railyard's Applications → Import reads your Render app's repository, variables and database straight from an API token and creates it on the server you pick.

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