Building VPS Hosting (Hetzner, etc.) with open-source tools
Transitioning from managed platforms like Vercel or Heroku to a VPS (Hetzner, OVH, etc.) requires manual implementation of security, routing, and deployment pipelines. This guide provides a production-ready sequence to transform a fresh Ubuntu installation into a hardened, automated application host using Docker and Caddy.
Initial Access and SSH Hardening
Disable password-based authentication and change the default SSH port to mitigate automated brute-force attacks. Always verify your key-based access in a second terminal before closing your current session.
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "your-public-key-content" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
sedo -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
sedo -i 's/PermitRootLogin yes/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
systemctl restart ssh⚠ Common Pitfalls
- •Locking yourself out of the server by disabling passwords before successfully testing SSH key access.
- •Forgetting to update the SSH port in your local ~/.ssh/config if you choose a non-standard port.
Firewall Configuration and Fail2Ban
Configure the Uncomplicated Firewall (UFW) to only allow essential traffic and install Fail2Ban to automatically ban IP addresses that exhibit malicious behavior.
apt update && apt install ufw fail2ban -y
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
systemctl enable fail2ban
systemctl start fail2ban⚠ Common Pitfalls
- •Enabling the firewall before allowing the SSH port, which will immediately drop your connection.
- •Over-restricting ports needed for Docker internal networking if using complex custom bridges.
Docker Engine and Compose Setup
Install Docker to containerize applications, ensuring environment parity between local development and the VPS. This eliminates 'works on my machine' issues common in manual VPS setups.
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
usermod -aG docker ${USER}
docker compose version⚠ Common Pitfalls
- •Using the outdated 'docker-compose' (Python version) instead of the modern 'docker compose' V2 plugin.
- •Failing to logout/login after adding the user to the docker group, resulting in permission denied errors.
Reverse Proxy Deployment with Caddy
Use Caddy as a reverse proxy to handle automatic SSL certificate issuance and renewal via Let's Encrypt. Caddy's configuration is significantly more concise than Nginx for standard VPS use cases.
app.example.com {
reverse_proxy localhost:3000
header {
Strict-Transport-Security "max-age=31536000;"
X-Content-Type-Options nosniff
X-Frame-Options DENY
}
}⚠ Common Pitfalls
- •DNS propagation delay: SSL issuance will fail if the A record for the domain does not yet point to the VPS IP.
- •Port conflicts if another service (like default Apache/Nginx) is already listening on port 80.
Automated Deployment via GitHub Actions
Set up a continuous deployment pipeline that builds a Docker image and triggers a remote update on the VPS. This avoids manual 'git pull' and 'docker restart' commands on the server.
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: root
key: ${{ secrets.SSH_KEY }}
script: |
cd /app
docker compose pull
docker compose up -d⚠ Common Pitfalls
- •Storing the private SSH key in plain text in the repository instead of using GitHub Secrets.
- •Failing to include a health check in the docker-compose.yml, which can lead to zero-downtime deployment failures.
Automated Backups and Maintenance
Configure unattended-upgrades for security patches and set up a cron job to backup application databases/volumes to off-site storage (e.g., S3 or Hetzner Storage Box).
apt install unattended-upgrades -y
dpkg-reconfigure -plow unattended-upgrades
# Example daily DB backup cron
0 2 * * * docker exec db_container pg_dump -U user dbname > /backups/db_$(date +\%F).sql⚠ Common Pitfalls
- •Assuming provider-level snapshots are sufficient; snapshots often don't guarantee database consistency unlike dumps.
- •Disk exhaustion caused by storing backups locally on the VPS without a rotation/cleanup policy.
What you built
Your VPS is now secured, routed via SSL, and integrated into a CI/CD pipeline. To scale further, consider moving from a single-server setup to a managed database (like Hetzner Managed Postgres) or implementing a lightweight orchestrator like Kamal or Coolify to manage multiple containers across different nodes.