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, thennpm 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 ed25519makes 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.
| Plan | vCPU | Memory | Disk | Traffic included | Price per month |
|---|---|---|---|---|---|
| Hetzner CPX22, Helsinki | 2 | 4 GB | 80 GB | 20 TB | €16.99, excl. VAT, IPv4 included |
| DigitalOcean Basic, Regular CPU | 1 | 2 GiB | 50 GiB | 2,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:
- Deploy a Docker Compose app to Hetzner with GitHub Actions uses a tested cloud-init file and Hetzner's firewall.
- Deploy a Docker app to a DigitalOcean Droplet installs Docker by hand over SSH and uses DigitalOcean's Cloud Firewall.
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:5432on Compose's private network; nothing on the internet can. - PostgreSQL 18's volume goes on
/var/lib/postgresql, not on/var/lib/postgresql/dataas in older guides: the official image keeps the data in a version-named folder below it. APP_TAGpicks the release. It lives in the.envfile next tocompose.yaml, with the database password, so a deploy changes one line and runsdocker 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.comwith the server's IPv4 address, and an AAAA record with its IPv6 address if it has one; - the same for
www, or a CNAME fromwwwtoexample.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:
- 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, withpackages: write, is enough to push. - 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 runsdocker 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
.envfile next tocompose.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.dumpRun 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 thanroot. - 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 regulardocker compose pullfor the images. - Know that the
dockergroup is root. Docker's documentation says thedockergroup 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
deployuser, 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
- Do it yourself if you want to understand and own every part. Start with step 1 and the article for your provider, and set aside time each month for updates and a restore test.
- Use a hosting platform (Vercel (opens in a new tab), Render (opens in a new tab), Railway (opens in a new tab)) if you would rather pay the platform and never see a server. Our comparison of your own server and a hosting platform sets out what you trade.
- Use a tool on your own server if you want the server's price and control without doing each step by hand. That is what OpsCrew is for; the setup page goes through it step by step.
Key facts
| Fact | Value | Source |
|---|---|---|
| Hetzner CPX22 (2 vCPU, 4 GB, 80 GB, 20 TB traffic) in Helsinki | 16.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 authorities | Let's Encrypt, with ZeroSSL as the fallback | Caddy documentation, Automatic HTTPS (opens in a new tab)Verified on |
| Docker's json-file log driver, default max-size | Unlimited (-1): no rotation unless you set max-size | Docker documentation, JSON File logging driver (opens in a new tab)Verified on |
| Versions used in this guide's tests | Docker 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
- Hetzner Cloud, Regular Performance plans (opens in a new tab)
- Hetzner Cloud, Cost-Optimized plans (opens in a new tab)
- Droplet pricing (opens in a new tab)
- Install Docker Engine on Ubuntu (opens in a new tab)
- JSON File logging driver (opens in a new tab)
- Automatic HTTPS (opens in a new tab)
- reverse_proxy directive (opens in a new tab)
- Working with the Container registry (opens in a new tab)
- Using secrets in GitHub Actions (opens in a new tab)
- Hetzner Cloud billing FAQ (opens in a new tab)
- Linux post-installation steps for Docker Engine (opens in a new tab)
- How to self-host your Next.js application (opens in a new tab)
- Env variables and modes (opens in a new tab)
Related articles
- 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.
- 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.
- 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.