I wanted a single server to run all my apps and services without paying for overpriced PaaS platforms. Here’s how I set one up using a Hetzner VPS, Dokploy, and Tailscale. Everything stays behind a private network with zero open inbound ports.
- Hetzner - Affordable cloud VPS provider with solid performance.
- Dokploy - Open-source, self-hosted PaaS for deploying and managing apps with Docker and Traefik.
- CrowdSec - Open-source security tool that blocks brute-force attacks and malicious IPs.
- Tailscale - Zero-config mesh VPN for private access to your server and admin tools.
Steps
- Steps
- Choosing a VPS Plan
- Initial Server Setup
- Creating a Sudo User
- Hardening SSH
- Adding Swap
- Setting Up CrowdSec
- Installing Dokploy
- Installing Tailscale
- Locking Down the Firewall
- Deploying Apps
- Migrating Existing Apps
Choosing a VPS Plan
I went with a CPX32 in Helsinki (eu-central): 4 vCPU, 8GB RAM, 160GB SSD. Check Hetzner’s cloud pricing page for current rates.
Why this tier:
- 8GB gives comfortable headroom for Dokploy + Traefik + multiple containerized apps. 4GB plans hit swap quickly once you’re running a few containers.
- EU plans generally have more traffic allowance and lower prices than US plans for the same specs.
- Helsinki vs Ashburn latency difference is negligible for most workloads. The bottleneck is usually external API calls, not server location.
- Hetzner lets you resize to a bigger plan without migrating if you outgrow it.
Initial Server Setup
SSH into your new server:
ssh root@your_vps_ip
If you’re using Ghostty and see Error opening terminal: xterm-ghostty, fix it with:
export TERM=xterm-256color
Update system packages:
sudo apt update && sudo apt upgrade -y
Creating a Sudo User
Don’t use root for daily operations. Create a dedicated user:
adduser dimitri
usermod -aG sudo dimitri
Configure passwordless sudo (since we’re using SSH key auth):
echo "dimitri ALL=(ALL) NOPASSWD:ALL" | sudo tee /etc/sudoers.d/dimitri
sudo chmod 0440 /etc/sudoers.d/dimitri
Copy your SSH keys from root to the new user:
mkdir -p /home/dimitri/.ssh
cp /root/.ssh/authorized_keys /home/dimitri/.ssh/
chown -R dimitri:dimitri /home/dimitri/.ssh
chmod 700 /home/dimitri/.ssh
chmod 600 /home/dimitri/.ssh/authorized_keys
Test the connection in a new terminal before closing the root session:
ssh dimitri@your_vps_ip
sudo whoami # should return "root"
Hardening SSH
Once you’ve confirmed the new user works, lock things down.
Edit the SSH config:
sudo nano /etc/ssh/sshd_config
Make these changes:
# Disable root login
PermitRootLogin no
# Disable password authentication (key-only access)
PasswordAuthentication no
# Change default SSH port to reduce bot noise
Port 2222
# Disconnect idle sessions after 15 minutes
ClientAliveInterval 900
ClientAliveCountMax 0
Restart SSH:
sudo systemctl restart ssh
From now on, connect with:
ssh -p 2222 dimitri@your_vps_ip
Tip: add this to your ~/.ssh/config on your local machine so you don’t have to remember the port:
Host myserver
HostName your_vps_ip
User dimitri
Port 2222
Then just ssh myserver.
Adding Swap
With 8GB RAM and 160GB disk, an 8GB swap (1:1 ratio) is a good safety net:
sudo fallocate -l 8G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
sudo sysctl vm.swappiness=10
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf
Setting swappiness to 10 means the system only uses swap under real pressure, not proactively.
Verify it’s working:
free -h
Setting Up CrowdSec
CrowdSec monitors your server for brute-force attacks and automatically blocks malicious IPs. Even with Tailscale handling most access, CrowdSec adds an extra layer of defense.
Install CrowdSec:
curl -s https://install.crowdsec.net | sudo sh
sudo apt update && sudo apt install crowdsec
Install the firewall bouncer:
sudo apt install crowdsec-firewall-bouncer-iptables -y
Verify it’s active:
sudo iptables -L
You should see a CROWDSEC_CHAIN in the output.
Verify CrowdSec is monitoring SSH:
sudo systemctl status crowdsec
sudo cscli collections list
sudo cscli bouncers list
Since we changed SSH to port 2222, CrowdSec still picks it up. It monitors /var/log/auth.log regardless of which port SSH runs on.
Useful CrowdSec commands for monitoring:
sudo cscli decisions list # view active bans
sudo cscli alerts list # recent security alerts
sudo cscli metrics # security metrics
Installing Dokploy
Install Dokploy:
sudo sh -c "$(curl -sSL https://dokploy.com/install.sh)"
This sets up Dokploy with Docker and Traefik. Once it finishes, don’t worry about accessing the dashboard yet. We’ll reach it through Tailscale in the next step.
Installing Tailscale
Tailscale creates a private mesh network between your devices and server. Once it’s set up, you can access admin tools like the Dokploy panel and SSH without exposing any ports to the public internet.
Install Tailscale on Your Devices First
Install Tailscale on your local machine, phone, or whatever you want to access your server from. Mac, Linux, iOS, Android all supported. Download from tailscale.com/download.
Get the Docker Network Subnet
We need the Docker network subnet so Tailscale can route traffic to your containers directly. Without this, you can only reach the host, not the individual services running inside Docker.
First, add your user to the docker group so you don’t need sudo for every Docker command:
sudo usermod -aG docker dimitri
newgrp docker
Then grab the subnet:
docker network inspect dokploy-network | grep Subnet
You’ll see something like:
"Subnet": "10.254.0.0/24",
Copy that subnet value. You’ll need it in the next step.
Install Tailscale on the Server
Go to the Tailscale Admin Console, click Add a device > Linux, and it’ll generate an install command. Run the install portion, then bring Tailscale up with the flags for SSH access and subnet routing:
tailscale up --ssh --advertise-routes=10.254.0.0/24
Replace 10.254.0.0/24 with the subnet from the previous step if yours differs.
The --ssh flag enables Tailscale SSH, which means you can SSH into the server through Tailscale without managing SSH keys separately. The --advertise-routes flag tells Tailscale to route traffic to the Docker network, so you can reach containers directly from your devices.
You’ll likely see warnings about IP forwarding and UDP GRO. IP forwarding is required for subnet routing to work (the server needs to pass traffic between Tailscale and Docker). UDP GRO improves WireGuard throughput. Fix both:
# Enable IP forwarding for subnet routing
echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf
# Optimize UDP forwarding for WireGuard
sudo ethtool -K eth0 rx-udp-gro-forwarding on rx-gro-list off
Make the ethtool change persist across reboots:
cat << 'EOF' | sudo tee /etc/networkd-dispatcher/routable.d/50-tailscale
#!/bin/sh
ethtool -K eth0 rx-udp-gro-forwarding on rx-gro-list off
EOF
sudo chmod 755 /etc/networkd-dispatcher/routable.d/50-tailscale
Then re-run the tailscale up command to clear the warnings.
Approve Subnet Routes
After running the command, go to the Tailscale Admin Console, find your server, click the … menu, and select Edit route settings. Approve the advertised subnet routes. This step is required or your clients won’t be able to reach Docker containers.
Verify the Connection
sudo tailscale status
tailscale ip -4
You’ll get a 100.x.x.x IP. This is the private IP for all admin access.
Test that you can reach the Dokploy dashboard from your local machine by opening http://100.x.x.x:3000 in your browser. Create your admin account.
Since we’re keeping the Dokploy panel behind Tailscale, there’s no need to set up a domain or SSL for it. Only devices on your tailnet can reach it.
Update Your SSH Config
With Tailscale running, update your ~/.ssh/config to use the Tailscale IP:
Host myserver
HostName 100.x.x.x
User dimitri
Port 2222
You can now SSH via Tailscale with just ssh myserver. With the --ssh flag, you can also use Tailscale SSH directly (no port needed):
ssh dimitri@100.x.x.x
Enable MagicDNS (Optional)
Instead of remembering 100.x.x.x IPs, enable MagicDNS in the Tailscale Admin Console under the DNS tab. This gives your server a hostname like your-server-name.tailscale.ts.net.
With MagicDNS you can access things like:
http://your-server-name.tailscale.ts.net:3000 # Dokploy panel
http://your-server-name.tailscale.ts.net:8080 # n8n
Much nicer than remembering IPs and ports.
Locking Down the Firewall
There’s an important gotcha here: Docker manipulates iptables directly, which means Docker-published ports can bypass local firewall rules (like UFW) entirely. Your iptables rules might say “drop everything” but Docker will still happily expose published ports to the public internet.
The solution is to use Hetzner’s Cloud Firewall, which operates at the network level before traffic even hits your server. Docker can’t bypass what never arrives.
In the Hetzner Cloud Console:
- Go to Firewalls and create a new firewall.
- Add these inbound rules:
| Protocol | Port | Source | Description |
|---|---|---|---|
| TCP | 2222 | Your IP (optional fallback) | SSH fallback |
No rules for ports 80 or 443. We’re not serving anything to the public internet. Tailscale traffic comes through the WireGuard tunnel, not the public IP, so it bypasses the Hetzner firewall by design. Docker can publish whatever ports it wants — they’re all blocked at the infrastructure level before they reach the server.
The SSH rule is an optional safety net in case Tailscale goes down. You can restrict it to your home IP or remove it entirely if you trust Tailscale completely.
- Apply the firewall to your server.
Deploying Apps
With everything behind Tailscale, all your apps are only accessible from devices on your tailnet. Access them at http://100.x.x.x:port or via MagicDNS at http://your-server.tailscale.ts.net:port.
To deploy an app, go to the Dokploy dashboard and create a new service. Dokploy supports three approaches: Git repo deployments (with webhook auto-deploy), Docker Compose stacks, and raw Docker containers. Most self-hosted apps like n8n have Docker Compose configs you can drop right in.
If you eventually need to make specific apps publicly accessible, you can add that later with something like Cloudflare Tunnels. No firewall changes required.
Migrating Existing Apps
If you’re moving apps from other hosting, the process is straightforward: set up the service in Dokploy, configure environment variables and volumes, deploy, verify it works, then cut over DNS. Dokploy handles the Docker orchestration and Traefik routing automatically.
If you outgrow the CPX32, migrating the server itself is low risk. Snapshot through Hetzner’s dashboard, spin up a bigger instance, restore. Or go fresh: install Dokploy on a new VPS and redeploy your apps. With everything containerized through Dokploy, there’s not much state on the host beyond Docker volumes.