Deploying

How to deploy a web app to a VPS: the complete guide

From an empty server to a production app: server size, Docker, a reverse proxy with HTTPS, DNS, CI/CD, secrets, backups, zero-downtime updates, previews, logs and hardening.

TL;DR

  • Rent a small Linux server (2 to 4 GB of memory is enough for most web apps with a database) and run your app, its database and Caddy (opens in a new tab) with Docker (opens in a new tab) Compose; Caddy gets the HTTPS certificate by itself once your domain's DNS record points at the server and ports 80 and 443 are open.
  • Build the image in CI (GitHub Actions (opens in a new tab)), push it to a registry and have the server pull it by its commit tag, so rolling back is deploying the previous tag.
  • Keep secrets in a file on the server and in GitHub (opens in a new tab)'s encrypted secrets, never in git (opens in a new tab) or in the image, and dump the database every day, keeping a copy off the server.
  • Close every port except SSH, HTTP and HTTPS in the provider's firewall, log in with keys only, rotate Docker's logs and turn on automatic security updates.
On this page

To deploy a web app to a VPS, you rent a small Linux server, install Docker, and run three containers with Docker Compose: your app, its database and a reverse proxy such as Caddy that gets the HTTPS certificate by itself. GitHub Actions builds each release as an image and tells the server to pull it. A server with 2 to 4 GB of memory, enough for a typical app with a database, was listed at €9.99 to €16.99 a month at Hetzner (opens in a new tab) (Helsinki, excluding VAT) and $12 to $24 at DigitalOcean (opens in a new tab) on 11 November 2026.

If an AI builder made your app, start with deploying an app you built with AI, which covers what those builders produce, or the guide for yours (Lovable, Replit, Bolt.new, v0 or Claude Code), then come back here for the server side.

What you need before you deploy an app to a VPS

  • The app in a Git repository on GitHub, which builds and starts with a command (npm run build, then npm start, or the equivalent for your stack).
  • A domain, or a subdomain of one you own, and access to its DNS records.
  • An SSH key on your computer (ssh-keygen -t ed25519 makes one).
  • Docker on your computer, so you can try every file in this guide locally before it touches a server.

The files and commands in this guide were tested in Docker on 11 November 2026 with Docker 29.9.3, Docker Compose 5.7, Caddy 2.11.9, PostgreSQL (opens in a new tab) 18.6, Node.js (opens in a new tab) 24.22.2 and a fresh Next.js (opens in a new tab) 16.4.6 app. Steps that need a real cloud account or registrar, and the log settings in step 12, come from the providers' and Docker's documentation.

1. Choose a provider and a server size

For one small production app, any of the usual VPS providers works. What differs is price, data-centre locations, included traffic and what the provider adds around the server: backups, snapshots, a firewall outside the server, managed databases. We compare the two most common choices, plan by plan, in Hetzner vs DigitalOcean for a small production app.

Two small servers, as listed on 11 November 2026
PlanvCPUMemoryDiskTraffic includedPrice per month
Hetzner CPX22, Helsinki24 GB80 GB20 TB€16.99, excl. VAT, IPv4 included
DigitalOcean Basic, Regular CPU12 GiB50 GiB2,000 GiB$12, IPv4 included

How much memory? In our tests the Next.js app, PostgreSQL 18 and Caddy used under 150 MB together at idle. That is the floor, not the plan: real traffic, the database's cache, a second copy of the app during zero-downtime updates, and the operating system all take more. Plan on 2 GB as the minimum for an app with a database and 4 GB as comfortable. Build images in CI, never on the production server: in our test, building the default Next.js app peaked at about 780 MB of memory, more than ten times what the running app used.

Which region? The one closest to most of your users, and inside the EU if your users' data must stay there.

2. Create the server

Pick Ubuntu 24.04 LTS (or 26.04 LTS; Docker's packages support both), add your SSH public key, and attach the provider's firewall with three inbound rules: TCP 22 (SSH), 80 (HTTP) and 443 (HTTPS), plus UDP 443 if you want HTTP/3. Both providers let you paste a cloud-init file that installs Docker and creates a deploy user on the first boot. The provider walk-throughs show every field:

If you already pay for a server somewhere else, the rest of this guide applies unchanged: it only assumes Ubuntu, SSH and Docker.

3. Package the app as a Docker image

Most web frameworks have an official or well-known Dockerfile, and the pattern is the same for all of them: install dependencies, build, then copy only what runs into a small final image that starts as a non-root user.

For Next.js the key setting is output: "standalone" in next.config.ts, which makes the build emit a self-contained server.js with only the files it needs. Our tested multi-stage Dockerfile, a health route and the build-time traps are in Deploy Next.js to a VPS with Docker and HTTPS.

Give the app a health route, such as /api/health, that answers 200 when the app can serve. Docker, the reverse proxy and your deploy script all use it to tell a working release from a broken one.

4. Run the app, the database and the proxy with Compose

This is the Compose file we ran: the app from its registry image, PostgreSQL with its data in a named volume, and Caddy as the only container with published ports.

services:
  app:
    image: ghcr.io/your-name/myapp:${APP_TAG:-latest}
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@db:5432/app
    depends_on:
      db:
        condition: service_healthy

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

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

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

Three details matter:

  • The database publishes no port. The app reaches it as db:5432 on Compose's private network; nothing on the internet can.
  • PostgreSQL 18's volume goes on /var/lib/postgresql, not on /var/lib/postgresql/data as in older guides: the official image keeps the data in a version-named folder below it.
  • APP_TAG picks the release. It lives in the .env file next to compose.yaml, with the database password, so a deploy changes one line and runs docker compose up -d.

5. Put a reverse proxy in front, with HTTPS

The reverse proxy is the only thing that faces the internet: it terminates HTTPS, redirects HTTP to HTTPS, compresses responses and passes requests to the app. Next.js's own self-hosting guide recommends one, because it absorbs malformed requests and slow clients before they reach the app. With Caddy, this is the whole configuration:

example.com {
	encode zstd gzip
	reverse_proxy app:3000
}

www.example.com {
	redir https://example.com{uri} permanent
}

Caddy gets and renews certificates from Let's Encrypt, with ZeroSSL as its fallback. According to its documentation it needs three things: the domain's DNS records point at the server before Caddy starts, ports 80 and 443 are reachable from outside, and its data folder survives restarts (the caddy-data volume above).

We checked this file with caddy validate and ran the stack locally with one change, tls internal inside each site block (a public certificate authority cannot reach a laptop, so Caddy signs its own certificate; leave it out in production). The site answered over HTTPS, www redirected with a 301, and the database was reachable only from the app's network.

nginx (opens in a new tab) with certbot does the same job with more configuration; Traefik (opens in a new tab) reads its routes from container labels.

6. Point your domain at the server

At your DNS provider, create:

  • an A record for example.com with the server's IPv4 address, and an AAAA record with its IPv6 address if it has one;
  • the same for www, or a CNAME from www to example.com.

Create the records before you start Caddy, so its first certificate request succeeds, and check them from your computer with dig +short example.com. A short TTL (300 seconds) while you set things up lets you correct a mistake quickly. If you put a CDN or proxy in front of the server later, follow its guide for encrypting and verifying the connection between it and your server too.

7. Automate deploys with GitHub Actions

Every merge to main should build an image, push it and update the server, the same way every time. The tested shape is two jobs:

  1. Build: check out the code, build the image with Docker's official actions, and push it to GitHub Container Registry (GHCR) tagged with the commit SHA. The workflow's own GITHUB_TOKEN, with packages: write, is enough to push.
  2. Deploy: connect to the server over SSH with a key from GitHub's encrypted secrets, check the server's host key against a pinned value, and run a deploy script that writes the new tag into .env, pulls the image and runs docker compose up -d --wait.

The full workflow, which passes actionlint, and the server-side script, which we ran against a local registry, are in Deploy a Docker Compose app to Hetzner with GitHub Actions. The same workflow deploys to any VPS: only the secrets change.

Because each release is an image tagged with its commit, rolling back is running the same script with the previous SHA, without rebuilding.

8. Keep secrets out of git and out of the image

Production secrets (the database password, API keys, a session secret) belong in two places only:

  • On the server, in the .env file next to compose.yaml, readable only by the deploy user (chmod 600 .env).
  • In GitHub's encrypted secrets, for what the workflow itself needs: the SSH key, the server's host key, the server's address. GitHub does not pass these secrets to workflows that run for pull requests from forks.

Never bake secrets into the image (an ENV line or a copied .env file); anyone who can pull the image can read them. In Next.js and Vite apps, variables with the public prefix (NEXT_PUBLIC_, VITE_) are copied into the browser bundle at build time, so they must be set when CI builds and must never hold a secret. For apps generated by AI builders, our guide to deploying an AI-built app shows which variables each builder expects.

9. Back up the database, and keep a copy elsewhere

A server can be lost to a mistaken command, a bad migration or a provider incident. Two layers of backup cover a small app.

A daily logical dump. PostgreSQL's own tool writes a consistent copy while the database runs. We dumped a table, dropped it, and restored it with these two commands:

docker compose exec -T db pg_dump -U app -d app -Fc > app-backup.dump
docker compose exec -T db pg_restore -U app -d app --clean --if-exists < app-backup.dump

Run the dump from cron every night and copy the file off the server, for example to object storage with versioning.

The provider's server backups or snapshots. Hetzner's backups cost 20% of the server's price and keep seven daily copies; DigitalOcean's cost 20% (weekly) or 30% (daily) of the Droplet's price. They restore the whole disk, which is the quickest way back from a broken server. A dump is finer: you can restore the database, or one table, onto any server, at any provider.

Then test a restore, before the day you need one.

10. Update without downtime, and roll back

The deploy in step 7 recreates the app container, and requests fail until the new one answers: in our test, 8 of 1,225 requests failed with 502 over about five seconds. For many small apps that is fine. When it is not, you need two copies of the app, a proxy that sends traffic only to copies that pass their health check, and an update that replaces one copy at a time.

We built that with plain Compose and Caddy and tested it locally: in three full updates under constant load, all 53,978 requests returned 200, and a release that failed its health check stopped the rollout while the old version kept serving. The files, the script and the limits (database migrations, WebSockets, Next.js's version skew) are in Zero-downtime deployments with Docker Compose.

Rolling back is a deploy of the previous image tag: keep a few recent tags in the registry and the last few deploys' SHAs in your workflow history, and make your database migrations backwards compatible, so the previous version still runs against the migrated schema.

11. Preview every pull request

A preview runs each pull request at its own address, with its own throwaway database, and is removed when the pull request closes. On a VPS that needs a second server (or at least strict separation), a wildcard DNS record, a proxy route per preview, a way to protect previews from strangers and automatic cleanup. We built and tested a do-it-yourself version in A preview of every pull request, on your own server.

12. Logs and monitoring

Logs. docker compose logs -f app shows the app's output. Docker's default json-file log driver does not rotate logs (its max-size defaults to unlimited), so a chatty app can fill the disk. Set rotation for every container in /etc/docker/daemon.json, then restart Docker:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

The setting applies to containers created after the restart, so recreate yours (docker compose up -d --force-recreate).

Monitoring. At minimum, three checks: an uptime check from outside that requests your health route every minute and alerts you, a disk-space alert, and a check that certificates renew (Caddy renews them in the background; an outside check catches it if renewal keeps failing). docker stats shows each container's CPU and memory at the moment. For history and dashboards, a hosted service or a self-hosted Prometheus and Grafana stack are the usual next steps.

13. Harden the server: the essentials

  • SSH with keys only. Turn off password logins (PasswordAuthentication no) once your key works, and log in as a user rather than root.
  • The provider's firewall, not only ufw. Docker's documentation warns that ports published by containers bypass ufw and firewalld rules. A firewall at the provider sits outside the server, where Docker cannot open holes in it. Allow 22, 80 and 443; nothing else.
  • Publish only the proxy's ports. The database and the app have no ports: entry in Compose.
  • Automatic security updates for the operating system (Ubuntu's unattended-upgrades), and regular docker compose pull for the images.
  • Know that the docker group is root. Docker's documentation says the docker group grants root-level privileges; give it only to the deploy user and keep that user's key in one place.

Doing this with OpsCrew

OpsCrew is an open-source tool that does this job with AI agents and shows you every change first. It reads your repository, asks a few questions such as your domain and budget, proposes server options with their monthly cost, then shows the exact infrastructure plan and creates nothing until you approve that plan by its hash (how it works).

On a server at Hetzner Cloud or DigitalOcean, or a server you already have, it:

  • sets the server up with a deploy user, key-only SSH, a firewall that lets in only SSH and web traffic, fail2ban, automatic security updates and Docker with log limits (security); on a server you already have, it lists each of these changes in a host plan you approve first;
  • runs your database next to the app with a generated password and no public port (databases);
  • puts Caddy (or nginx) in front with HTTPS, security headers and TLS 1.2 and 1.3 only (the reverse proxy, certificates), and can buy or connect your domain on Cloudflare (opens in a new tab) (domains);
  • opens a pull request with a GitHub Actions deploy workflow that runs on pushes that change your app (when deploys run), and switches releases without downtime: a new version gets traffic only after its health check passes, and OpsCrew switches back by itself when the site does not answer afterwards (servers with less than 1 GB of memory restart the app in place instead, with a short pause);
  • rolls back with one command, without rebuilding (rollback), takes snapshots of your data with an encrypted copy off the server, and gives every pull request a preview;
  • shows logs and resource figures from your terminal (logs and status), with optional self-hosted monitoring.

OpsCrew is free; you pay the provider for the server (pricing).

Which path fits you

Key facts

FactValueSource
Hetzner CPX22 (2 vCPU, 4 GB, 80 GB, 20 TB traffic) in Helsinki16.99 EUR per month, excl. VATRegular Performance plan, IPv4 included; €19.99 in Nuremberg. Hetzner's page marked its cheaper Cost-Optimized plans as not available on the day we checked.Hetzner Cloud, Regular Performance (opens in a new tab)Verified on
DigitalOcean Basic Droplet (1 vCPU, 2 GiB, 50 GiB, 2,000 GiB transfer)12 USD per monthRegular CPU; a public IPv4 address is included in Bundled plans.DigitalOcean Droplet pricing (opens in a new tab)Verified on
Caddy's default certificate authoritiesLet's Encrypt, with ZeroSSL as the fallbackCaddy documentation, Automatic HTTPS (opens in a new tab)Verified on
Docker's json-file log driver, default max-sizeUnlimited (-1): no rotation unless you set max-sizeDocker documentation, JSON File logging driver (opens in a new tab)Verified on
Versions used in this guide's testsDocker 29.9.3, Compose 5.7, Caddy 2.11.9, PostgreSQL 18.6, Node.js 24.22.2, Next.js 16.4.6Tested in Docker on 11 November 2026.Verified on

Frequently asked questions

How much does it cost to host a web app on a VPS?

The server is most of it: on 11 November 2026 a 2 vCPU, 4 GB server cost €16.99 a month excluding VAT at Hetzner (Helsinki) and a 1 vCPU, 2 GiB Droplet $12 a month at DigitalOcean. Add the domain's yearly fee and, if you want them, the provider's backups (20% of the server price at Hetzner; 20% weekly or 30% daily at DigitalOcean). Reverse proxy, certificates, Docker and GitHub Actions for a public repository cost nothing.

Do I need Docker to deploy an app to a VPS?

No, but it makes the rest easier. Without Docker you install the runtime, a process manager and the database on the server yourself, and each release changes files in place. With Docker the app ships as an image built in CI, the database and proxy are two more lines in a Compose file, and rolling back is starting the previous image.

Sources

  1. Hetzner Cloud, Regular Performance plans (opens in a new tab), Hetzner, accessed on
  2. Hetzner Cloud, Cost-Optimized plans (opens in a new tab), Hetzner, accessed on
  3. Droplet pricing (opens in a new tab), DigitalOcean, accessed on
  4. Install Docker Engine on Ubuntu (opens in a new tab), Docker, accessed on
  5. JSON File logging driver (opens in a new tab), Docker, accessed on
  6. Automatic HTTPS (opens in a new tab), Caddy, accessed on
  7. reverse_proxy directive (opens in a new tab), Caddy, accessed on
  8. Working with the Container registry (opens in a new tab), GitHub, accessed on
  9. Using secrets in GitHub Actions (opens in a new tab), GitHub, accessed on
  10. Hetzner Cloud billing FAQ (opens in a new tab), Hetzner, accessed on
  11. Linux post-installation steps for Docker Engine (opens in a new tab), Docker, accessed on
  12. How to self-host your Next.js application (opens in a new tab), Vercel (Next.js), accessed on
  13. Env variables and modes (opens in a new tab), Vite, accessed on
  • Deploying

    Deploy a Docker Compose app to Hetzner with GitHub Actions

    A Hetzner Cloud server set up by a tested cloud-init file, behind Hetzner's firewall, updated by a GitHub Actions workflow that builds your image, pushes it to GHCR and deploys it over SSH.

    10 min read

  • Deploying

    Deploy Next.js to a VPS with Docker and HTTPS

    A tested setup for Next.js 16 on your own server: standalone output, a small multi-stage Docker image, PostgreSQL and Caddy with automatic HTTPS, and the build-time traps that break apps after the first deploy.

    8 min read

  • Deploying

    Deploy a Docker app to a DigitalOcean Droplet

    Create a Droplet, lock it down with a Cloud Firewall, install Docker from Docker's own repository, and run your app with Compose and automatic HTTPS. Every command tested; prices checked on 11 November 2026.

    8 min read

Put OpsCrew to work on your app.

Run npx opscrew init in your app's repository. It checks your tools and tokens first, for free.