2026.06 / infrastructure guide 020
Setting Up a Hetzner VPS for Self-Hosting: From Bare Metal to Docker in 30 Minutes
Hetzner Cloud offers some of the best price-to-performance ratios for self-hosting. A single shared-CPU instance with 4 GB RAM costs roughly the same as a large coffee per month, and it can comfortably run Docker, a reverse proxy, a few web apps, a mail server, and a database.
This is the setup path I use. It covers picking the right server, provisioning it, applying the first hardening steps, installing Docker, and confirming a container is reachable on the public internet. Everything after that — Coolify, Traefik, mail, monitoring — is just more containers on the same foundation.
Goal: go from "no server" to "a hardened Docker host running a reachable web service" in a single sitting.
Why Hetzner for self-hosting
A few reasons it works well for independent projects and small services:
- Price. A CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) is affordable enough to experiment with. Scaling up or adding volumes is straightforward when needed.
- Performance. Hetzner's shared vCPU instances are faster than many competitors' equivalently-priced plans. Real-world Docker workloads benefit from the better single-thread performance.
- Simple billing. Hourly billing with a monthly cap, no surprise charges, and a clean console for managing servers, floating IPs, volumes, and firewalls.
- Data centres in Europe. Frankfurt, Nuremberg, and Helsinki give good latency for European audiences and decent latency for the eastern US.
- No vendor lock-in. Everything runs on standard Docker. If you move to another provider later, your containers and compose files travel with you.
Picking the right server size
Start smaller than you think. A self-hosted setup with a static blog, a few web apps, and Docker Mailserver can run comfortably on 4 GB RAM and 2 vCPUs.
| Workload | Minimum RAM | Notes |
|---|---|---|
| Static sites + reverse proxy only | 2 GB | nginx or Caddy, a handful of HTML sites |
| Static sites + a few small apps | 4 GB | Good starting point for most self-hosters |
| Add Docker Mailserver + Postgres | 4–8 GB | Mail and databases are the real memory consumers |
| Full stack with Coolify | 6–8 GB | Coolify itself uses a couple of GB plus its dependencies |
Hetzner lets you resize later without reinstalling, so starting at 4 GB and upgrading when monitoring shows pressure is a sensible approach.
Creating the server
In the Hetzner Cloud Console:
- Click Add Server.
- Choose a location close to your audience.
- Select Ubuntu 24.04 (LTS, well-supported, Docker works cleanly).
- Pick the CX22 or higher depending on the table above.
- Add an SSH key (generate one locally if you do not have one). This is the only way to log in if you disable password auth later.
- Name the server something meaningful —
prod-vps-1is better thanserver-1. - Click Create. The IP address appears within seconds.
Do not attach a floating IP yet unless you know you need one for a migration. The server's primary IPv4 is sufficient for most single-server setups.
First SSH connection
ssh root@YOUR_SERVER_IP
The first thing to do on a fresh server is create a non-root user with sudo access:
adduser deploy
usermod -aG sudo deploy
su - deploy
Test that sudo works:
sudo whoami
# should output: root
SSH key-only access
Copy your public key to the new user:
mkdir -p ~/.ssh
chmod 700 ~/.ssh
# On your local machine:
ssh-copy-id deploy@YOUR_SERVER_IP
Then disable password authentication for SSH. Edit /etc/ssh/sshd_config:
PasswordAuthentication no
PermitRootLogin prohibit-password
sudo systemctl restart sshd
Test the change in a separate terminal before closing the current session. If you get locked out, Hetzner's console rescue mode will let you fix it.
Firewall basics
Hetzner provides a firewall in the cloud console, and UFW runs inside the server. Using both is defence in depth.
Hetzner Cloud Firewall rules for a self-hosting server:
- Allow TCP 22 (SSH) from your IP only.
- Allow TCP 80 and 443 from anywhere (web traffic through your reverse proxy).
- Allow TCP 25 from anywhere (inbound SMTP if running a mail server).
- Deny everything else.
UFW inside the server:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 25/tcp # if self-hosting email
sudo ufw enable
For the complete hardening picture — fail2ban, swap, sysctl tuning, automatic security upgrades — see the VPS hardening checklist.
Installing Docker
Docker's official install script is the fastest path on a fresh Ubuntu server:
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker deploy
# Log out and back in, or:
newgrp docker
Verify:
docker run --rm hello-world
That should pull a tiny image, print a welcome message, and exit. If it does, Docker is working.
Docker Compose v2 is bundled with Docker's install script on modern Ubuntu, so docker compose version should also work without a separate install.
Your first self-hosted container
A good first container is a static web server that serves a simple test page. This confirms Docker networking works and that something is reachable from the public internet.
mkdir -p ~/test-site
echo '<h1>Hello from Hetzner</h1>' > ~/test-site/index.html
docker run -d \
--name test-nginx \
-p 80:80 \
-v ~/test-site:/usr/share/nginx/html:ro \
nginx:alpine
Visit http://YOUR_SERVER_IP in a browser. If you see the test page, Docker and networking are working. Clean up afterward:
docker stop test-nginx
docker rm test-nginx
Docker Compose for real workloads
For anything beyond a test, use Compose. A minimal docker-compose.yml for a web app:
services:
app:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./html:/usr/share/nginx/html:ro
restart: unless-stopped
docker compose up -d
The restart: unless-stopped policy means the container survives server reboots, which is important for a VPS that may be restarted for kernel upgrades.
Reverse proxy considerations
With multiple services, you need something to route traffic by hostname. Two common approaches for a single VPS:
- Standalone Traefik or Caddy as the reverse proxy, with each container connecting via a Docker network. Clean, automatic TLS via Let's Encrypt.
- Coolify, which includes Traefik and manages routing, deployments, SSL, and health checks. More overhead, but a full PaaS experience. See the AI app factory post for how I use it.
For a single-server self-hosting setup, either approach works. Coolify is worth the RAM if you want push-to-deploy without writing compose files by hand. Traefik or Caddy alone is lighter if you prefer managing infrastructure directly.
DNS and public access
Point your domain's A record at the Hetzner server IP:
example.com. A YOUR_SERVER_IP
*.example.com. A YOUR_SERVER_IP
If you use Cloudflare as a DNS proxy, set the web records to Proxied (orange cloud) and keep mail records as DNS only (grey cloud). See the Cloudflare DNS checklist for the full breakdown.
What to set up next
Once Docker is running and you can reach a container from the internet, the order I recommend is:
- Reverse proxy — Traefik, Caddy, or Coolify's built-in proxy.
- SSL certificates — Let's Encrypt via the proxy or certbot.
- First real service — a blog, a dashboard, or whatever you are building.
- Fail2ban and automatic security upgrades — see the hardening checklist.
- Backups — database dumps, volume snapshots, and Hetzner's snapshot feature.
- Monitoring — even a simple health check endpoint or uptime ping.
- Email (optional) — see how email is handled on this VPS and the multi-domain Docker Mailserver guide.
Cost reference
These are approximate monthly prices in EUR for Hetzner Cloud shared-vCPU instances at the time of writing. Actual pricing may vary.
| Plan | vCPU | RAM | Disk | Traffic |
|---|---|---|---|---|
| CX11 | 2 | 2 GB | 20 GB | 20 TB |
| CX22 | 2 | 4 GB | 40 GB | 20 TB |
| CX32 | 4 | 8 GB | 80 GB | 20 TB |
The 20 TB monthly traffic allowance is generous. Most small self-hosted services use well under 100 GB per month.
Common first-day mistakes
- Forgetting to add SSH key before disabling passwords. Test key-based SSH in a second terminal before changing the config.
- Opening too many ports. Only 22, 80, 443, and optionally 25 need to be public. Everything else should stay closed.
- Not setting up automatic security upgrades. Ubuntu's
unattended-upgradespackage handles this; see the hardening checklist. - Running everything as root. Use a deploy user, Docker socket permissions, and sudo only when needed.
- Ignoring backups until something breaks. Hetzner snapshots are cheap. Set up a weekly schedule from day one.
Practical checklist
- Choose a Hetzner Cloud server size (CX22 or higher for most self-hosted setups).
- Provision with Ubuntu 24.04 LTS and an SSH key.
- Create a non-root sudo user and disable password SSH.
- Apply firewall rules (Hetzner cloud firewall + UFW).
- Install Docker and verify with a test container.
- Run your first real service behind a reverse proxy.
- Point your domain's DNS at the server IP.
- Set up fail2ban, automatic upgrades, and backups.
- Expand from there — email, monitoring, more services, CI/CD.
The whole process, from creating the server to running a reachable container, takes about 30 minutes. The remaining steps — hardening, monitoring, backup schedules — are incremental. The important thing is getting the foundation right: a properly sized server, key-only SSH, basic firewall, and a working Docker environment. Everything else builds on top.