the Goal
We are building a clean Docker gateway VM inside Proxmox.
This VM will later run:
Docker
Traefik
Portainer
n8n
PostgreSQL
Cloudflare wildcard SSL
Important design rule:
Do not install Docker, Traefik, directly on the Proxmox host.
Keep the Proxmox host clean.
Run services inside VMs.
Architecture
Hetzner Dedicated Server
โ
Proxmox Host
โ
VM 100: docker-gateway
โ
Docker Engine
โ
Traefik container
VM 100 uses a private network:
Proxmox vmbr1: 10.10.100.1/24
VM 100: 10.10.100.100/24
Gateway: 10.10.100.1
Traefik runs inside VM 100, not on the Proxmox host.
Security Strategy
For the first setup:
Hetzner firewall: ON
Proxmox firewall: OFF
Traefik: reverse proxy only
Public TCP 80/443: do not open until Traefik is ready
Traefik will later control:
HTTP 80
HTTPS 443
domain routing
SSL certificates
redirects
middlewares
dashboard protection
Traefik does not control:
SSH
Proxmox firewall
Hetzner firewall
Jitsi UDP 10000
TURN ports
Linux kernel firewall
Step 1 โ Create VM 100 in Proxmox
Recommended VM settings:
VM ID: 100
Name: docker-gateway
OS: Debian 13 minimal
CPU: 4 cores
RAM: 8 GB
Disk: 200 GB
Storage: local-zfs
Network: vmbr1
Model: VirtIO
Firewall: OFF
Use Debian minimal install.
During install, select only:
standard system utilities
If there is no DHCP, continue without network and configure static networking later.
Step 2 โ Create Private Bridge vmbr1 on Proxmox
In Proxmox UI:
pve1 โ System โ Network โ Create โ Linux Bridge
Use:
Name: vmbr1
IPv4/CIDR: 10.10.100.1/24
Gateway: blank
Bridge ports: blank
Autostart: checked
VLAN aware: unchecked
Comment: Private VM NAT network
Do not edit vmbr0.
Apply configuration.
Step 3 โ Connect VM 100 to vmbr1
In Proxmox:
VM 100 โ Hardware โ Network Device โ Edit
Set:
Bridge: vmbr1
Model: VirtIO
Firewall: OFF
Reboot VM 100.
Step 4 โ Configure Static IP inside VM 100
RUN THIS ON: VM 100 console
Become root:
su -
Edit network config:
nano /etc/network/interfaces
Use:
auto lo
iface lo inet loopback
auto ens18
iface ens18 inet static
address 10.10.100.100/24
gateway 10.10.100.1
dns-nameservers 1.1.1.1 8.8.8.8
Save:
CTRL + O
Enter
CTRL + X
Reboot:
reboot
Test private gateway:
ping -c 3 10.10.100.1
Success:
3 packets transmitted, 3 received
Step 5 โ Add Proxmox NAT for VM Internet
RUN THIS ON: Proxmox host over SSH
From Mac Terminal:
ssh proxmox
Then on Proxmox:
sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -s 10.10.100.0/24 -o vmbr0 -j MASQUERADE
iptables -A FORWARD -i vmbr1 -o vmbr0 -s 10.10.100.0/24 -j ACCEPT
iptables -A FORWARD -i vmbr0 -o vmbr1 -d 10.10.100.0/24 -m state --state RELATED,ESTABLISHED -j ACCEPT
Test from VM 100:
ping -c 3 1.1.1.1
Success:
3 packets transmitted, 3 received
Step 6 โ Fix DNS inside VM 100
Important lesson learned:
The correct file is:
/etc/resolv.conf
Not:
/etc/resolve.conf
If DNS fails, check:
RUN THIS ON: VM 100 console
ls -la /etc/resolv.conf /etc/resolve.conf 2>&1
Correct DNS file:
nano /etc/resolv.conf
Use:
nameserver 1.1.1.1
nameserver 8.8.8.8
Test:
ping -c 3 deb.debian.org
Success:
3 packets transmitted, 3 received
Step 7 โ Fix Debian APT Sources
Because Debian was installed without a network mirror, fix:
RUN THIS ON: VM 100 console
nano /etc/apt/sources.list
Use:
# deb cdrom:[Debian GNU/Linux 13.5.0 _Trixie_ - Official amd64 NETINST with firmware]/ trixie contrib main non-free-firmware
deb http://deb.debian.org/debian trixie main contrib non-free-firmware
deb http://deb.debian.org/debian trixie-updates main contrib non-free-firmware
deb http://security.debian.org/debian-security trixie-security main contrib non-free-firmware
Run:
apt update
Then:
apt upgrade -y
Step 8 โ Install Basic Tools
RUN THIS ON: VM 100 console
apt install -y sudo openssh-server curl ca-certificates gnupg qemu-guest-agent
Enable services:
systemctl enable --now ssh
systemctl enable --now qemu-guest-agent
usermod -aG sudo dockeradmin
Reboot:
reboot
Log in again and test:
sudo whoami
Success:
root
Step 9 โ SSH into VM 100
From your Mac:
RUN THIS ON: Mac Terminal
ssh proxmox
Then:
RUN THIS ON: Proxmox host over SSH
ssh [email protected]
Success prompt:
dockeradmin@docker-gatway:~$
Step 10 โ Install Official Docker Engine
RUN THIS ON: VM 100 over SSH
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
Add Docker repo:
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
Update:
sudo apt update
Install Docker:
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Test:
sudo docker run hello-world
Success:
Hello from Docker!
Step 11 โ Use Docker Without Sudo
RUN THIS ON: VM 100 over SSH
sudo usermod -aG docker dockeradmin
Refresh session:
newgrp docker
Test:
docker ps
Success:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Test Compose:
docker compose version
Step 12 โ Create Docker Folder Structure
RUN THIS ON: VM 100 over SSH
sudo mkdir -p /opt/docker/{traefik,portainer,n8n,postgres}
sudo chown -R dockeradmin:dockeradmin /opt/docker
Create Traefik network:
docker network create proxy
Confirm:
docker network ls
Success:
proxy
Step 13 โ Make Proxmox NAT Persistent
RUN THIS ON: Proxmox host over SSH
Create sysctl file:
cat > /etc/sysctl.d/99-vmbr1-nat.conf << 'EOF'
net.ipv4.ip_forward=1
EOF
sysctl --system
Create NAT script:
cat > /usr/local/sbin/vmbr1-nat.sh << 'EOF'
#!/bin/sh
set -eu
LAN_CIDR="10.10.100.0/24"
LAN_IF="vmbr1"
WAN_IF="vmbr0"
case "${1:-start}" in
start)
sysctl -w net.ipv4.ip_forward=1 >/dev/null
iptables -t nat -C POSTROUTING -s "$LAN_CIDR" -o "$WAN_IF" -j MASQUERADE 2>/dev/null || \
iptables -t nat -A POSTROUTING -s "$LAN_CIDR" -o "$WAN_IF" -j MASQUERADE
iptables -C FORWARD -s "$LAN_CIDR" -i "$LAN_IF" -o "$WAN_IF" -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -s "$LAN_CIDR" -i "$LAN_IF" -o "$WAN_IF" -j ACCEPT
iptables -C FORWARD -d "$LAN_CIDR" -i "$WAN_IF" -o "$LAN_IF" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -d "$LAN_CIDR" -i "$WAN_IF" -o "$LAN_IF" -m state --state RELATED,ESTABLISHED -j ACCEPT
;;
stop)
iptables -t nat -D POSTROUTING -s "$LAN_CIDR" -o "$WAN_IF" -j MASQUERADE 2>/dev/null || true
iptables -D FORWARD -s "$LAN_CIDR" -i "$LAN_IF" -o "$WAN_IF" -j ACCEPT 2>/dev/null || true
iptables -D FORWARD -d "$LAN_CIDR" -i "$WAN_IF" -o "$LAN_IF" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
;;
*)
echo "Usage: $0 {start|stop}"
exit 1
;;
esac
EOF
chmod +x /usr/local/sbin/vmbr1-nat.sh
Create service:
cat > /etc/systemd/system/vmbr1-nat.service << 'EOF'
[Unit]
Description=Persistent NAT for Proxmox private VM network vmbr1
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/vmbr1-nat.sh start
ExecStop=/usr/local/sbin/vmbr1-nat.sh stop
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
Enable:
systemctl daemon-reload
systemctl enable --now vmbr1-nat.service
Check:
systemctl status vmbr1-nat.service --no-pager
Success:
Active: active (exited)
Step 14 โ Create Traefik YAML Files
RUN THIS ON: VM 100 over SSH
mkdir -p /opt/docker/traefik/data
touch /opt/docker/traefik/docker-compose.yaml
touch /opt/docker/traefik/data/traefik.yml
touch /opt/docker/traefik/data/config.yml
touch /opt/docker/traefik/data/acme.json
chmod 600 /opt/docker/traefik/data/acme.json
Confirm:
ls -la /opt/docker/traefik
ls -la /opt/docker/traefik/data
Step 15 โ Create traefik.yml
RUN THIS ON: VM 100 over SSH
cat > /opt/docker/traefik/data/traefik.yml << 'EOF'
api:
dashboard: true
entryPoints:
http:
address: ":80"
https:
address: ":443"
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
file:
filename: /config.yml
watch: true
log:
level: INFO
accessLog: {}
EOF
Step 16 โ Create config.yml
RUN THIS ON: VM 100 over SSH
cat > /opt/docker/traefik/data/config.yml << 'EOF'
http:
middlewares:
security-headers:
headers:
frameDeny: true
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: "strict-origin-when-cross-origin"
customRequestHeaders:
X-Forwarded-Proto: "https"
https-redirect:
redirectScheme:
scheme: https
permanent: true
EOF
Step 17 โ Create Traefik docker-compose.yaml
RUN THIS ON: VM 100 over SSH
cat > /opt/docker/traefik/docker-compose.yaml << 'EOF'
services:
traefik:
image: traefik:v3.6.2
container_name: traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- proxy
ports:
- "80:80"
- "443:443"
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./data/traefik.yml:/traefik.yml:ro
- ./data/config.yml:/config.yml:ro
- ./data/acme.json:/acme.json
networks:
proxy:
external: true
EOF
Validate:
cd /opt/docker/traefik
docker compose config
Success:
No YAML error
Step 18 โ Start Traefik
RUN THIS ON: VM 100 over SSH
Make sure you are inside:
/opt/docker/traefik
Run:
docker compose up -d
Check:
docker ps
Success:
traefik Up
Check logs:
docker logs traefik --tail=50
Success:
Traefik version 3.6.2
Starting provider *file.Provider
Starting provider *traefik.Provider
Starting provider *docker.Provider
Important Issue We Hit
Using:
traefik:v3.0
caused this error:
client version 1.24 is too old. Minimum supported API version is 1.40
Fix:
sed -i 's/image: traefik:v3.0/image: traefik:v3.6.2/' /opt/docker/traefik/docker-compose.yaml
Then:
cd /opt/docker/traefik
docker compose pull
docker compose up -d
After switching to:
traefik:v3.6.2
Traefik started cleanly.
Current Status
VM 100 installed
Private NAT works
Persistent NAT service active
DNS works
APT works
Docker installed
Docker Compose installed
Docker works without sudo
Traefik files created
Traefik running as container
Traefik v3.6.2 works cleanly
Traefik is listening inside VM 100 on:
80
443
But it is not publicly exposed yet because we have not opened public TCP 80/443 in Hetzner firewall.
Next Steps
After this tutorial stage:
1. Install Portainer with YAML
2. Add protected subdomain: portainer.[DOMAIN]
3. Add protected subdomain: traefik.[DOMAIN]
4. Add n8n with PostgreSQL
5. Add Cloudflare DNS challenge
6. Add wildcard SSL certificate
7. Open TCP 80/443 in Hetzner firewall only when ready
8. Add WireGuard later for private admin access
Recommended subdomains:
traefik.[DOMAIN]
portainer.[DOMAIN]
n8n.[DOMAIN]
Do not expose dashboards publicly without protection.
Use:
strong app passwords
basic auth where needed
Cloudflare protection if desired
VPN/IP allowlist later


