VPNSmith
self-host-vpnINFO

Self-host VPN on Contabo: complete WireGuard guide 2026

Step-by-step WireGuard setup on a Contabo VPS (€4.99/mo). Bootstrap scripts, UFW, fail2ban, macOS/iOS/Linux/Windows clients. Honest limits vs commercial VPN.

By Eric Gerard · Fondateur · VPNSmith — Spécialiste self-host VPN & VPS GDPR14 min readPhoto: Taylor Vick — Unsplash

Tired of paying €12.99/month to NordVPN after the promo year. You want an encrypted tunnel shared with no one, hosted in a GDPR jurisdiction, whose configuration you control line by line. Good news: it stands up in 20 minutes on a Contabo VPS at €4.99/month, and this guide gives you every command. No marketing, no abstraction — just the script we run in production for 14 months.

Why self-host rather than a commercial service

A commercial VPN sells you a promise: no logs, shared IP across thousands of users, "no-logs friendly" jurisdiction (Panama, BVI, Switzerland). On paper it's solid. In practice:

  • Shared IP = shared reputation. When 4,000 NordVPN users exit through the same IP, you inherit their blacklists. Stripe will ask for an extra 3DS. Cloudflare will serve you endless challenges. You host your own VPN = your own clean IP.
  • No-logs unverifiable in real time. A PwC audit validates a policy at one moment. Between audits, court orders (US subpoenas, EU orders) can force temporary logging. On your VPS, you decide. You purge whenever you want.
  • Prices that double on renewal. NordVPN 2yr: €71.76. Yearly renewal: €156/yr. Over 5 years: ~€600. A Contabo VPS S over 5 years: ~€300. And you can host other services on it.

The honest tradeoff: you won't unblock Netflix US with a dedicated VPS (Netflix bans datacenter ASNs). For streaming, we document a hybrid solution in the DPI bypass guide.

Picking the right Contabo VPS

Contabo offers three relevant ranges for a personal VPN:

PlanPrice/movCPURAMBandwidthBest for
VPS S (legacy)€4.9948 GB200 MbpsSolo VPN 1-3 devices
VPS S Cloud€6.994 vCPU NVMe8 GB600 MbpsFamily VPN 5-10 devices
Cloud VPS 10€9.996 vCPU16 GB1 GbpsMulti-tunnel + extra services

Our default pick: VPS S at €4.99/mo. For 90% of individual uses, more than enough. 200 Mbps bandwidth is guaranteed (iperf3-tested, sustained 195 Mbps).

Recommended datacenter: Nuremberg (DE) for <30 ms latency from continental Europe. If you're in Canada/US, pick US Central (St. Louis).

Step-by-step setup

Step 1 — Provision the VPS

On contabo.com, pick VPS S, Ubuntu 24.04 LTS, root password (we'll create a normal user later), Nuremberg datacenter. Pay via card or PayPal. Activation: email within ~5 minutes with the public IP and root password.

Step 2 — Harden the server

First SSH connection:

ssh root@YOUR_PUBLIC_IP
# Password is in the Contabo email

Create a non-root user, add your SSH key, disable root login:

adduser ericg
usermod -aG sudo ericg
mkdir -p /home/ericg/.ssh
# Paste your public key (~/.ssh/id_ed25519.pub locally)
nano /home/ericg/.ssh/authorized_keys
chmod 700 /home/ericg/.ssh
chmod 600 /home/ericg/.ssh/authorized_keys
chown -R ericg:ericg /home/ericg/.ssh

# Disable root + password login
sed -i 's/^PermitRootLogin .*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart ssh

Exit the root shell, reconnect via ssh ericg@YOUR_IP. If it works, continue.

Install firewall and fail2ban:

sudo apt update && sudo apt upgrade -y
sudo apt install -y ufw fail2ban
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 51820/udp
sudo ufw --force enable
sudo systemctl enable --now fail2ban

Step 3 — Install and configure WireGuard

sudo apt install -y wireguard qrencode

# Server key generation
cd /etc/wireguard
sudo wg genkey | sudo tee server_private.key | sudo wg pubkey | sudo tee server_public.key
sudo chmod 600 server_private.key
SERVER_PRIV=$(sudo cat server_private.key)

Identify your public interface (often ens3 or eth0):

ip route | grep default
# default via X.X.X.X dev ens3 ...

Create /etc/wireguard/wg0.conf:

sudo nano /etc/wireguard/wg0.conf

Content (replace ens3 with your interface if different):

[Interface]
PrivateKey = PASTE_SERVER_PRIV_HERE
Address = 10.66.66.1/24
ListenPort = 51820
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o ens3 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o ens3 -j MASQUERADE

Enable IP forwarding:

echo "net.ipv4.ip_forward=1" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p

Start WireGuard:

sudo systemctl enable --now wg-quick@wg0
sudo wg show
# You should see "interface: wg0" and "listening port: 51820"

Step 4 — Generate a client

For each device:

cd /etc/wireguard
sudo wg genkey | sudo tee macbook_private.key | sudo wg pubkey | sudo tee macbook_public.key
CLIENT_PRIV=$(sudo cat macbook_private.key)
CLIENT_PUB=$(sudo cat macbook_public.key)
SERVER_PUB=$(sudo cat server_public.key)

Create /tmp/macbook.conf (on the server, to generate the QR — then delete):

[Interface]
PrivateKey = PASTE_CLIENT_PRIV
Address = 10.66.66.2/24
DNS = 1.1.1.1, 9.9.9.9

[Peer]
PublicKey = PASTE_SERVER_PUB
Endpoint = YOUR_PUBLIC_IP:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25

Add this peer to the server:

sudo wg set wg0 peer PASTE_CLIENT_PUB allowed-ips 10.66.66.2/32
# And persist:
sudo nano /etc/wireguard/wg0.conf
# Append at the bottom:
# [Peer]
# PublicKey = PASTE_CLIENT_PUB
# AllowedIPs = 10.66.66.2/32

For iOS/Android: generate the QR code

qrencode -t ansiutf8 < /tmp/macbook.conf

Scan with the official WireGuard app. Toggle on. You're done.

Step 5 — Verify no leaks

On your client connected to the tunnel:

  1. Go to ipleak.net — IP must be the Contabo VPS, not yours.
  2. Go to browserleaks.com/webrtc — no local IP exposed.
  3. Go to dnsleaktest.com — DNS resolved via 1.1.1.1 (Cloudflare).

If a WebRTC leak shows: enable kill switch in WireGuard mobile ("On-Demand"), or block WebRTC in Firefox (about:configmedia.peerconnection.enabledfalse).

Additional hardening

Recommended production hardenings:

  • Lynis audit: sudo apt install lynis && sudo lynis audit system. Target score: 80+/100.
  • SSH on custom port: change /etc/ssh/sshd_config port 2222, update UFW.
  • Disable IPv6 if unused: sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1 (persist in sysctl.conf).
  • Minimum logs: by default, journald retains 4 weeks. Reduce to 7 days via /etc/systemd/journald.conf if threat model is strict.
  • Contabo backups: enable automatic snapshots in the Contabo panel (€1/mo, not for logs but for quick recovery after a mishap).

Automated setup via an Ansible playbook

If you plan to provision more than one VPS — or just want to rebuild your tunnel in 3 minutes after an incident — automating via Ansible avoids retyping the commands above. Here's the playbook we run in production. Idempotent (safe to rerun), tested on Ubuntu 24.04 LTS Contabo Nuremberg in May 2026.

Local prerequisites

# On your laptop
pip install --user ansible
mkdir -p ~/vpn-ansible && cd ~/vpn-ansible

Create inventory.yml (replace the IP with the one Contabo emailed you):

all:
  hosts:
    vpn1:
      ansible_host: YOUR_PUBLIC_IP
      ansible_user: root
      ansible_python_interpreter: /usr/bin/python3

The playbook.yml

---
- name: Bootstrap VPNSmith WireGuard on Contabo
  hosts: vpn1
  become: yes
  vars:
    admin_user: ericg
    admin_ssh_key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
    wg_subnet: "10.66.66.0/24"
    wg_port: 51820
  tasks:

    - name: Update apt cache
      apt:
        update_cache: yes
        cache_valid_time: 3600

    - name: Upgrade all packages
      apt:
        upgrade: dist
        autoremove: yes

    - name: Install hardening + WireGuard packages
      apt:
        name:
          - ufw
          - fail2ban
          - wireguard
          - qrencode
          - unattended-upgrades
        state: present

    - name: Create admin user
      user:
        name: "{{ admin_user }}"
        groups: sudo
        shell: /bin/bash
        password: "!"

    - name: Authorize admin SSH key
      authorized_key:
        user: "{{ admin_user }}"
        key: "{{ admin_ssh_key }}"
        state: present

    - name: Disable SSH root login + passwords
      lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "{{ item.regex }}"
        line: "{{ item.line }}"
      loop:
        - { regex: '^#?PermitRootLogin', line: 'PermitRootLogin no' }
        - { regex: '^#?PasswordAuthentication', line: 'PasswordAuthentication no' }
      notify: restart ssh

    - name: Configure UFW default policies
      ufw:
        direction: "{{ item.direction }}"
        policy: "{{ item.policy }}"
      loop:
        - { direction: incoming, policy: deny }
        - { direction: outgoing, policy: allow }

    - name: Allow SSH + WireGuard through UFW
      ufw:
        rule: allow
        port: "{{ item.port }}"
        proto: "{{ item.proto }}"
      loop:
        - { port: 22, proto: tcp }
        - { port: "{{ wg_port }}", proto: udp }

    - name: Enable UFW
      ufw:
        state: enabled
        policy: deny

    - name: Enable IP forwarding
      sysctl:
        name: net.ipv4.ip_forward
        value: "1"
        state: present
        reload: yes

    - name: Detect public interface
      shell: ip -o -4 route show to default | awk '{print $5}'
      register: pub_iface
      changed_when: false

    - name: Generate server WireGuard keys
      shell: |
        umask 077
        wg genkey | tee /etc/wireguard/server_private.key | wg pubkey > /etc/wireguard/server_public.key
      args:
        creates: /etc/wireguard/server_private.key

    - name: Read server keys
      slurp:
        src: "/etc/wireguard/{{ item }}"
      register: wg_keys
      loop:
        - server_private.key
        - server_public.key

    - name: Render wg0.conf
      copy:
        dest: /etc/wireguard/wg0.conf
        mode: '0600'
        content: |
          [Interface]
          PrivateKey = {{ wg_keys.results[0].content | b64decode | trim }}
          Address = 10.66.66.1/24
          ListenPort = {{ wg_port }}
          PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o {{ pub_iface.stdout }} -j MASQUERADE
          PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o {{ pub_iface.stdout }} -j MASQUERADE

    - name: Enable + start wg-quick@wg0
      systemd:
        name: wg-quick@wg0
        enabled: yes
        state: started

    - name: Enable + start fail2ban
      systemd:
        name: fail2ban
        enabled: yes
        state: started

  handlers:
    - name: restart ssh
      systemd:
        name: ssh
        state: restarted

Execution

ansible-playbook -i inventory.yml playbook.yml

Count ~3 minutes the first time (essentially apt upgrade). Tunnel is up. Your ericg user can SSH with the Ed25519 key. You can rerun the playbook as many times as you want without breaking anything — it won't overwrite the WireGuard keys (idempotence guaranteed via the creates: flag).

To add a client (handled in a second phase of the playbook, documented in the WireGuard config templates guide): generate client keys, render client.conf with Jinja2, push via QR encode.

Practical tip: version the playbook in a private Git repo (never public — the Ansible Vault keys aren't safe against public GitHub if the passphrase ever leaks). We use a self-hosted Gitea on the same VPS for perfect circularity.

Free uptime monitoring with UptimeRobot

If you want to know when your tunnel drops without refreshing manually, UptimeRobot covers 95% of the need for free. Free plan: 50 monitors, checks every 5 minutes, email + Telegram + Discord webhook alerts.

We monitor 3 distinct endpoints to disambiguate the failure type:

Monitor 1 — Server health (HTTPS ping of the IP)

UptimeRobot type "HTTP(s)", URL https://YOUR_PUBLIC_IP/healthz. To answer this check, install a lightweight Caddy on port 443:

sudo apt install -y caddy
sudo tee /etc/caddy/Caddyfile > /dev/null <<EOF
:443 {
  respond /healthz "OK" 200
  tls internal
}
EOF
sudo systemctl restart caddy
sudo ufw allow 443/tcp

If this monitor goes DOWN → the VPS itself is unreachable (Contabo network outage, or your VPS crashed).

Monitor 2 — Tunnel health (from Cloudflare Workers)

Set up a free Cloudflare Worker (100k req/day free tier) that fetches https://ifconfig.me with a custom user-agent. For the check to be meaningful, the Worker would need to traverse the WireGuard tunnel, which isn't directly possible with Workers — workaround with an UptimeRobot "Keyword" monitor hitting an endpoint you host that verifies the response contains the Contabo VPS IP (not a residential one).

Monitored URL: https://YOUR_PUBLIC_IP/whoami (Caddy proxies to ifconfig.me).

Keyword: the IPv4 of your VPS.

If routing breaks (DNS hijack, broken NAT), the returned IP would differ and the monitor would alert.

Monitor 3 — DNS health via 1.1.1.1

URL: https://1.1.1.1/cdn-cgi/trace, keyword "warp=off". This check verifies you can resolve Cloudflare from the VPS — i.e. that the VPS outbound route isn't broken.

Alert configuration

In UptimeRobot, set:

  • Primary email: you get pinged within 30s of detection
  • Telegram bot (free): push to your phone even off email
  • Threshold: "alert after 2 failed checks" to avoid network false positives

Over 14 months we got 3 UptimeRobot alerts on this monitoring: 2 planned Contabo maintenance windows + 1 case where Caddy crashed on a failed cert renewal. No false positives for 6 months.

Total cost: €0. UptimeRobot free + Cloudflare Workers free + Caddy free.

SSH hardening with fail2ban + key-only auth

Initial bootstrap disables SSH password, but in production we go further. Here's the hardening we apply to every Contabo VPS since April 2025, motivated by the ~3,000 SSH attempts/day we observe on ASN 51167 (bots permanently scan the Contabo space).

Step 1 — Custom SSH port

Changing the SSH port isn't "real" security (security through obscurity) but it cuts logs by 95%. Bots scan port 22 first.

sudo sed -i 's/^#Port 22/Port 2289/' /etc/ssh/sshd_config
sudo ufw delete allow 22/tcp
sudo ufw allow 2289/tcp
sudo systemctl restart ssh
# Verify from a separate terminal BEFORE closing the first:
# ssh -p 2289 ericg@YOUR_IP

⚠ Keep the old terminal open until you've confirmed the new-port connection works. Otherwise you lock yourself out.

Step 2 — Custom fail2ban SSH jail

fail2ban watches port 22 by default. Adjust:

sudo tee /etc/fail2ban/jail.d/sshd-custom.local > /dev/null <<EOF
[sshd]
enabled = true
port = 2289
maxretry = 3
findtime = 600
bantime = 86400
EOF
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd

Policy: 3 failures in 10 minutes → 24h ban. Stricter than the default (5 failures / 1h ban) because we don't allow passwords anyway — an auth failure = a bot.

Step 3 — Audit fail2ban quota

The typical SSH jail bans 30-80 IPs/day on Contabo Nuremberg. To check what's happening:

sudo fail2ban-client status sshd
# Currently banned IPs: 47
# Banned IP list: 185.x.x.x 91.x.x.x ...
sudo zcat /var/log/fail2ban.log* | grep "Ban " | wc -l
# Total bans since the beginning

If you see >200 bans/day, you can stiffen bantime = 604800 (1 week) to limit IO load on the server.

Step 4 — Ed25519 SSH keys with passphrase

If you haven't generated your Ed25519 key on the laptop, now's the time:

# On your laptop
ssh-keygen -t ed25519 -C "ericg@laptop-2026" -f ~/.ssh/id_ed25519_vpn
# Pick a strong passphrase (typed once/day via ssh-agent)
ssh-add -t 10800 ~/.ssh/id_ed25519_vpn  # forgets it after 3h

Ed25519 > RSA 4096: faster signatures, shorter keys, state of the art in 2026. Every Ubuntu 24.04 Contabo VPS supports Ed25519 natively.

Step 5 — Disable ChallengeResponseAuthentication

Make sure these lines are in /etc/ssh/sshd_config:

PubkeyAuthentication yes
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
AuthenticationMethods publickey

With AuthenticationMethods publickey, even if a 0-day CVE lets an attacker bypass PAM, they still need the key. Belt and suspenders.

After these 5 steps we went from ~3,000 SSH attempts/day to ~150 (the bots that brute-scan all 65535 ports). None succeed. You can sleep.

Monthly cost: Contabo VPS S vs NordVPN yearly

Honest 5-year calculation:

SolutionYr 1Yr 2Yr 3Yr 4Yr 5Total
NordVPN 2yr + renewals€71.76€0€156€156€156€539.76
Contabo VPS S Cloud 24 mo × 2€59.76€59.76€59.76€59.76€59.76€298.80

Savings: €241 over 5 years (45%). Bonus: you can host your Bitwarden, Nextcloud, Umami, whatever you want.

Honest self-host limitations

For completeness:

  • Netflix US streaming, BBC iPlayer, etc.: almost never works. Netflix detects datacenter ASNs (Contabo ASN 51167) and serves you the local catalog. For streaming, keep a NordVPN 1yr as a complement.
  • DPI bypass Iran/China: WireGuard on port 51820/udp in plaintext is detected by sophisticated DPI. For these cases, add V2Ray or Cloak as obfuscation (see the dedicated guide).
  • No chat support: Contabo replies via email within 24-48h. If you want live chat, check Hetzner Cloud (~2× the price, FR/DE/EN chat support).
  • You're the admin: if the VPS crashes, you debug. Opposite of a managed service.

Article published 2026-06-02, last updated 2026-06-03 (added: idempotent Ansible playbook, free 3-endpoint UptimeRobot monitoring, fail2ban + Ed25519 SSH hardening). Bootstrap script last verified on Ubuntu 24.04 LTS on 2026-06-03. If the procedure breaks for you, open an issue on our GitHub or email contact@vpnsmith.com.

★ Datacenter Nuremberg GDPR · ✓ IPv4 dédiée incluse · 200+ Mbps garantis

Get Contabo30 jours satisfait ou remboursé