> ## Content Index
> Fetch the complete content index at: https://blog.viveknet.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Self-Hosting Pangolin on a Hardened Ubuntu VPS: A Production Setup Guide
- URL: https://blog.viveknet.com/self-hosting-pangolin-on-a-hardened-ubuntu-vps/
- Published: 2026-09-28T09:00:00.000Z
- Updated: 2026-10-04T06:44:10.000Z
- Author: Vivek Chandran
- Tags: Docker

## Introduction

[Pangolin](https://github.com/fosrl/pangolin?ref=blog.viveknet.com) is an open source, self-hosted platform for publishing private services to the internet without opening inbound ports on the machines actually running them. It is built by Fossorial and is made of three parts working together rather than one single program.

The first part is Pangolin itself, the dashboard and API that ties everything together. Inside it you define organizations, users, and two kinds of objects: sites and resources. A site is any machine or private network you want to connect, whether that is a home lab, an office network, or another server somewhere else entirely. A resource is an individual service running on one of those sites that you choose to publish, whether that is a plain web app over HTTP or HTTPS, or a raw TCP or UDP service.

The second part is Gerbil, a small agent built on WireGuard. Each site runs Gerbil (or a lighter equivalent connector), which opens one outbound WireGuard tunnel back to your Pangolin server. That is the entire trick that removes the need for inbound ports or router configuration on the site's own network. Nothing needs to be reachable from the internet except the central server itself.

The third part is Traefik, doing the actual HTTP routing, TLS termination, and certificate management for whatever gets published, driven dynamically by whatever resources you define in the dashboard.

Pangolin ships as a free Community edition, with a paid Enterprise edition available for features aimed at larger or business deployments. Everything in this guide applies to either.

Most write-ups on Pangolin stop at a single `docker compose up` command. This one does not. Below is the process for standing up Pangolin on a bare Ubuntu VPS the way you would want it running in a small business, not a weekend homelab. That means containers running with the least privilege they can get away with, a real firewall model, pinned image versions, and an honest list of things that broke along the way. Every IP, domain and credential in this guide is a placeholder, so replace them with your own before use.

By the end of this guide you will have:

- Pangolin, Gerbil and Traefik running, each with the minimum Linux capabilities it actually needs
- A firewall model that separates what must be public (the proxy itself) from what must never be public (SSH, the admin dashboard, DNS, and anything else on the box)
- Automatic HTTPS through Let's Encrypt using the DNS challenge, which also works for services that have no public HTTP endpoint of their own
- A short list of real failures encountered on a brand new Ubuntu release, along with the actual fixes rather than just the happy path

## Why Pangolin, and why this shape of setup

There are several ways to put a private service on the internet, and it is worth being honest about the alternatives before picking one.

A plain reverse proxy with a port forward on your router works, but it means your own connection needs a static or reliably reachable public IP, and every service you expose is one misconfigured forwarding rule away from being reachable in ways you did not intend. That is a fragile foundation to build anything real on, especially from a residential connection where the IP can change and the router is not something you fully control.

Cloudflare Tunnel and Tailscale Funnel both solve the same underlying problem, and solve it well, but both tie the actual tunnel and access control layer to a third party's infrastructure and policies. That is a reasonable trade for a lot of people, and there is nothing wrong with choosing it. The point of self hosting Pangolin instead is that the control plane, the certificates, and the routing rules stay entirely under your own management, on infrastructure you pay for and administer directly.

What actually makes Pangolin useful here is Gerbil, the WireGuard based agent. The machine actually running your service never needs an open inbound port at all. It only needs one outbound WireGuard connection to the VPS, and the VPS is what the rest of the internet talks to. That single property removes most of the operational headache of exposing something from behind a home connection or an office network with no real firewall control.

On top of that, Pangolin gives you one dashboard to manage every exposed resource, with per-resource access rules, invite based signup, and Traefik doing the actual request routing underneath, which means the whole routing and middleware ecosystem Traefik already has is available for free. The same setup scales from one personal project to a handful of internal tools published for a small team, without changing the underlying architecture at all.

## Architecture

```
Internet
   │  80/443 tcp, 51820/21820 udp (WireGuard)
   ▼
┌─────────────────────────────────────────┐
│ Gerbil  (WireGuard tunnel + port owner)  │
│   └── network_mode: shared with Traefik  │
├─────────────────────────────────────────┤
│ Traefik (TLS termination, routing)       │
├─────────────────────────────────────────┤
│ Pangolin (dashboard, API, resource mgmt) │
└─────────────────────────────────────────┘
        │ Docker network (internal)
        ▼
  Your other self-hosted apps

```

Traefik shares Gerbil's network namespace, so the public ports (80 and 443) end up published on Gerbil rather than on Traefik itself. That is intentional and is how the upstream project wires the two components together.

## Prerequisites

You will need a fresh VPS with a public IPv4 address, a domain you control with DNS hosted somewhere that supports an API driven DNS challenge, and root or sudo access. Beyond that, here is the actual stack this guide is built on, so you know exactly what has been tested and what you are working with if you follow along.

**Operating system:** Ubuntu 26.04 LTS Server. This is a deliberately recent release, and it is worth knowing that going in, because one of the incidents further down only shows up on a kernel and container runtime combination this new. An older LTS release such as 24.04 works fine with everything in this guide and will not hit that particular issue.

**Hardening baseline:** the CIS (Center for Internet Security) Ubuntu Linux Benchmark, Level 1 Server profile, applied with one of the freely available CIS build kit tools rather than by hand. Level 1 Server is a reasonable default for an internet facing box. Level 2 goes further, but a couple of its items actively fight the Docker model used here, so it is worth staying at Level 1 unless you have a specific compliance reason to go higher.

**Base firewall:** firewalld, doing the normal job of restricting SSH and any other host level admin ports to trusted addresses. On top of that sits the `DOCKER-USER` iptables hook covered in Step 3, since firewalld's own zones are not enough on their own once Docker starts publishing container ports.

**Container runtime:** Docker CE, installed from Docker's own upstream repository rather than the older package that ships with the distribution, along with the Docker Compose v2 plugin.

**Container management:** everything in this guide is plain `docker compose` from the command line, and that is all you actually need. If you would rather manage stacks through a web interface, tools such as Portainer or newer lighter alternatives sit on top of the same compose files without changing anything described here. The testing behind this guide was done through one such tool, but nothing in the setup itself depends on it.

**DNS:** a domain with API driven DNS access. Cloudflare is used in the examples below, though any supported DNS provider works the same way.

## Step 1: base OS accounts and SSH

Do not run everything as root. Create a dedicated service account to own your Docker data, and a separate admin account for yourself.

I also like to keep every container's data under one root folder rather than scattered across the filesystem, so the first thing I do on any new box is create that folder and hand it to a dedicated account instead of root.

```bash
# A dedicated system account with no login shell, used only to own file permissions
# for the app data. Nothing actually runs "as" this user.
useradd --system --no-create-home --shell /usr/sbin/nologin svcdocker

# Your own admin account, used for actual sudo/docker work
adduser adminuser
usermod -aG sudo,docker adminuser

mkdir -p /srv/docker
chown root:svcdocker /srv/docker
chmod 2770 /srv/docker

```

Move SSH off port 22, and disable root login and password authentication once you have confirmed key based login is working.

```bash
# /etc/ssh/sshd_config.d/99-hardening.conf
Port 45000
PermitRootLogin no
PasswordAuthentication no

```

One thing worth knowing before you touch SSH at all: on very new Ubuntu releases, SSH is split into `ssh.socket`, which owns the actual network bind, and `ssh.service`, which just runs the daemon against a file descriptor that gets passed to it. The classic approach of editing the config file and running `systemctl restart ssh` does not reliably move the port on these releases, and restarting the live socket unit can lock you out in the middle of the change. The safer path is `systemctl disable ssh.socket` followed by `systemctl enable ssh.service`, then letting the cutover happen naturally on your next reboot rather than a manual restart of the unit your current session depends on. Keep your provider's console or recovery access open until you have confirmed the new port actually works.

It is also worth keeping any custom admin ports out of the kernel's ephemeral port range. A random outbound connection can transiently pick an admin port as its own source port, which causes an intermittent and genuinely confusing bind failure later.

```bash
# /etc/sysctl.d/99-hardening.conf
net.ipv4.ip_local_port_range = 49152 60999

```

## Step 2: Docker

Install Docker from its own repository rather than the older package that ships with the distro, and check the GPG key fingerprint before trusting it.

```bash
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /tmp/docker.gpg
# Compare this fingerprint against the one published on docs.docker.com before continuing
gpg --dry-run --import --import-options import-show /tmp/docker.gpg

install -m 0755 -d /etc/apt/keyrings
mv /tmp/docker.gpg /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
  | tee /etc/apt/sources.list.d/docker.list

apt update && apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin

```

## Step 3: the firewall model

This is the part most guides skip, and it is the actual difference between something that works and something that is safe to leave running unattended.

The core idea is simple. Default allow at the operating system firewall level, and explicit deny for anything that must stay private, applied specifically at the Docker layer. Docker published ports bypass your normal firewall zones entirely, because the network address translation happens before your firewall's own chain is ever evaluated. That means you cannot rely on `firewalld` or `ufw` rules alone to protect container ports. You need to hook into `DOCKER-USER`, the chain Docker guarantees runs before its own rules, and reject traffic to your private ports from anywhere except your own trusted address.

```bash
#!/bin/bash
# /usr/local/sbin/docker-user-fw.sh: restrict private Docker-published ports to a trusted IP.
# Public ports (80, 443, WireGuard) are deliberately left out of this list, since Pangolin
# needs those open to the world.
TRUSTED_IP=YOUR_TRUSTED_IP_HERE
IFACE=eth0
# proto:port pairs to restrict. Extend this as more admin only services get added.
RULES="tcp:45000"

for i in $(seq 1 30); do iptables -nL DOCKER-USER >/dev/null 2>&1 && break; sleep 1; done

for cmd in iptables ip6tables; do
  $cmd -S DOCKER-USER 2>/dev/null | grep -- '--comment private-only' | sed 's/^-A /-D /' | while read -r line; do $cmd $line; done
done

for r in $RULES; do
  proto=${r%%:*}; port=${r##*:}
  iptables -I DOCKER-USER -i "$IFACE" -p "$proto" -m conntrack --ctorigdstport "$port" --ctdir ORIGINAL ! -s "$TRUSTED_IP" -m comment --comment private-only -j DROP
  ip6tables -I DOCKER-USER -i "$IFACE" -p "$proto" -m conntrack --ctorigdstport "$port" --ctdir ORIGINAL -m comment --comment private-only -j DROP
done
exit 0

```

```ini
# /etc/systemd/system/docker-user-fw.service
[Unit]
Description=Restrict private Docker-published ports to a trusted IP
After=docker.service firewalld.service
Wants=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/docker-user-fw.sh

[Install]
WantedBy=multi-user.target

```

```bash
chmod +x /usr/local/sbin/docker-user-fw.sh
systemctl enable --now docker-user-fw.service

```

One detail that is not optional is the `--ctdir ORIGINAL` flag. Without it, reply packets belonging to your own containers' outbound connections, such as DNS lookups or NTP, also match the port filter and get silently dropped. That kind of failure is miserable to debug, because everything looks fine except the one thing quietly failing in the background. Always verify with `iptables -L DOCKER-USER -n -v` and watch the counters after applying restrictive rules, to make sure nothing legitimate is being caught by them.

## Step 4: the compose stack

Set up the directory layout first.

```bash
mkdir -p /srv/docker/pangolin/config/{traefik,letsencrypt}
chown -R root:svcdocker /srv/docker/pangolin

```

`compose.yaml`:

```yaml
services:
  pangolin:
    image: fosrl/pangolin:1.23.0   # pin a real version or digest in production
    container_name: pangolin
    restart: unless-stopped
    networks: [pangolin-net]
    volumes:
      - /srv/docker/pangolin/config:/app/config
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
      interval: 10s
      timeout: 10s
      retries: 15
    security_opt: [no-new-privileges:true]
    cap_drop: [ALL]
    read_only: true
    tmpfs: [/tmp]
    mem_limit: 1g
    pids_limit: 256
    logging:
      driver: json-file
      options: { max-size: "10m", max-file: "3" }

  gerbil:
    image: fosrl/gerbil:1.5.2
    container_name: gerbil
    restart: unless-stopped
    depends_on:
      pangolin:
        condition: service_healthy
    networks: [pangolin-net]
    command:
      - --reachableAt=http://gerbil:3004
      - --generateAndSaveKeyTo=/var/config/key
      - --remoteConfig=http://pangolin:3001/api/v1/
    volumes:
      - /srv/docker/pangolin/config:/var/config
    cap_add: [NET_ADMIN, SYS_MODULE, NET_RAW]   # required for WireGuard
    ports:
      - "51820:51820/udp"
      - "21820:21820/udp"
      - "443:443"
      - "80:80"
    security_opt: [no-new-privileges:true]
    cap_drop: [ALL]
    read_only: true
    tmpfs: [/tmp]
    mem_limit: 128m
    pids_limit: 100

  traefik:
    image: traefik:v3.7.13
    container_name: traefik
    restart: unless-stopped
    network_mode: service:gerbil     # ports appear on gerbil, not here
    depends_on:
      pangolin: { condition: service_healthy }
      gerbil:   { condition: service_started }
    env_file: [.env]                 # holds CF_DNS_API_TOKEN, never commit this file
    command: ["--configFile=/etc/traefik/traefik_config.yml"]
    volumes:
      - /srv/docker/pangolin/config/traefik:/etc/traefik:ro
      - /srv/docker/pangolin/config/letsencrypt:/letsencrypt
    security_opt: [no-new-privileges:true]
    cap_drop: [ALL]
    cap_add: [NET_BIND_SERVICE]
    read_only: true
    tmpfs: [/tmp]
    mem_limit: 256m
    pids_limit: 100

networks:
  pangolin-net:
    driver: bridge

```

`config/traefik/traefik_config.yml`:

```yaml
api:
  insecure: false
  dashboard: true

providers:
  http:
    endpoint: "http://pangolin:3001/api/v1/traefik-config"
    pollInterval: "5s"
  file:
    filename: "/etc/traefik/dynamic_config.yml"

experimental:
  plugins:
    badger:
      moduleName: "github.com/fosrl/badger"
      version: "v1.7.0"

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"
    http:
      tls:
        certResolver: "letsencrypt"

certificatesResolvers:
  letsencrypt:
    acme:
      email: "you@example.com"
      storage: "/letsencrypt/acme.json"
      dnsChallenge:
        provider: cloudflare       # swap for your own DNS provider
        delayBeforeCheck: 0

ping:
  entryPoint: "web"

```

`config/traefik/dynamic_config.yml` holds the dashboard's own fixed routes. The individual "resources" you add later through Pangolin's own dashboard are pushed dynamically through the `http` provider configured above, so you will not need to hand edit this file for those.

```yaml
http:
  middlewares:
    badger:
      plugin:
        badger:
          disableForwardAuth: true
    redirect-to-https:
      redirectScheme: { scheme: https }
  routers:
    redirect:
      rule: "Host(`pangolin.example.com`)"
      entryPoints: [web]
      middlewares: [redirect-to-https, badger]
      service: next-service
    main:
      rule: "Host(`pangolin.example.com`) && !PathPrefix(`/api/v1`)"
      entryPoints: [websecure]
      middlewares: [badger]
      service: next-service
      tls: { certResolver: letsencrypt }
    api:
      rule: "Host(`pangolin.example.com`) && PathPrefix(`/api/v1`)"
      entryPoints: [websecure]
      middlewares: [badger]
      service: api-service
      tls: { certResolver: letsencrypt }
    ws-router:                        # WebSocket traffic from Newt/site clients
      rule: "Host(`pangolin.example.com`)"
      entryPoints: [websecure]
      middlewares: [badger]
      service: api-service
      tls: { certResolver: letsencrypt }
  services:
    next-service:
      loadBalancer: { servers: [{ url: "http://pangolin:3002" }] }
    api-service:
      loadBalancer: { servers: [{ url: "http://pangolin:3000" }] }

```

`.env` should hold a Cloudflare token scoped only to DNS editing on the one zone you actually need. Do not use an account wide key here.

```
CF_DNS_API_TOKEN=your-scoped-cloudflare-token

```

`config/config.yml` is Pangolin's own application config. Generate a real random secret rather than reusing the placeholder shown here.

**Read the `base_endpoint` line carefully.** It must be your server's raw public IP address (or a DNS name that is *not* proxied through Cloudflare). If your dashboard domain sits behind Cloudflare's orange cloud proxy, putting that domain here will break every site connector in a way that is very hard to diagnose. See the first incident below.

```yaml
gerbil:
  start_port: 51820
  base_endpoint: "203.0.113.10"   # public IP, NOT a Cloudflare-proxied hostname (WireGuard UDP is not proxied)
app:
  dashboard_url: "https://pangolin.example.com"
  log_level: "info"
domains:
  domain1:
    base_domain: "example.com"
traefik:
  http_entrypoint: "web"
  https_entrypoint: "websecure"
  cert_resolver: "letsencrypt"
  site_types: ["newt", "wireguard", "local"]
server:
  secret: "REPLACE_WITH_A_LONG_RANDOM_STRING"
  cors:
    origins: ["https://pangolin.example.com"]
    methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
    allowed_headers: ["X-CSRF-Token", "Content-Type"]
flags:
  disable_signup_without_invite: true
  allow_raw_resources: true

```

Now bring it up.

```bash
cd /srv/docker/pangolin
docker compose up -d
docker compose logs -f pangolin   # watch for the one time setup token

```

Visit `https://pangolin.example.com/auth/initial-setup`, use the setup token from the logs, and create your admin account there. Do this yourself in a browser rather than scripting your own account credentials.

## Step 5: email, which is optional but worth doing

If you already have an SMTP relay you are authorized to send from, whether that is a real mailbox or an authorized relay connector, point Pangolin at it so password resets and invites actually work.

```yaml
email:
  smtp_host: "smtp.example.com"
  smtp_port: 587
  smtp_secure: false
  no_reply: "noreply@example.com"

```

The `smtp_user` and `smtp_pass` fields are optional. If your relay authorizes by source IP address rather than by credentials, you can leave them out entirely.

## Real world incidents

These are genuine failures that came up while building this exact stack, kept in deliberately, because knowing what actually goes wrong is more useful than a guide that pretends nothing ever does.

**Site connectors (Newt) reach the dashboard but never come up: `newt/wg/get-config` times out after 16 attempts, and pings fail.**

This one took the longest to find, because almost everything looked healthy. The Newt client connected to the dashboard's WebSocket without any error, the server logs showed nothing wrong, and a manual ping to the server worked. Yet the connector logged `SendMessageInterval timed out after 16 attempts for message type: newt/wg/get-config` on every start and never received its WireGuard configuration. Along the way the following were ruled out, one at a time: DNS inside the client container, the host firewall, Traefik's WebSocket routing, a stale client key, and a client config that was not being persisted.

The actual cause was a single line in Pangolin's `config.yml`. `gerbil.base_endpoint` had been set to the dashboard's hostname, and that hostname was proxied through Cloudflare. Cloudflare's standard proxy carries HTTP, HTTPS and WebSocket traffic only. It never forwards raw UDP, so the WireGuard ports (51820 and 21820) were silently dropped at Cloudflare's edge. The control channel over HTTPS worked perfectly, which is exactly what made it look like a software fault rather than a network one.

The fix is to set `base_endpoint` to the server's public IP address, restart the Pangolin stack, and restart the connector. The tunnel then came up immediately and every published resource started proxying.

The fastest way to find this kind of problem is a line by line diff of `config.yml` against a deployment that is known to work, rather than trusting that two similar setups are configured the same way. If you need a hostname rather than an IP, create a separate DNS record with the Cloudflare proxy turned off (grey cloud) and use that.

**A site connector that keeps changing its public key, or loses its identity on every restart.**

Different connector images store their identity in different places. The older `newt` image keeps its state under `/root/.config/newt-client/`, while the newer `pangolin-cli` image reads `/root/.config/pangolin/site.json`. If your bind mount points at the wrong path, nothing is persisted, so the client generates a fresh key on every start and the server sees an endless series of strangers. Check the client's own startup log for the config path it actually reads, mount that directory, and make sure it exists with the right permissions before the container starts.

**A connector that cannot resolve the dashboard hostname on a minimal host, with `lookup ... on 127.0.0.11:53: connection refused`.**

Docker's embedded resolver at `127.0.0.11` relies on `iptables` rules to redirect queries. On a stripped down host that has no `iptables` at all, such as many NAS operating systems, that redirect never exists. Setting `dns:` in the compose file does not help, because it only changes where the broken resolver would forward to. Bind mount a plain `resolv.conf` into the container instead, and the container will talk to your chosen resolver directly.

```yaml
    volumes:
      - /path/to/resolv.conf:/etc/resolv.conf:ro

```

**All containers failing with `fork/exec /proc/self/fd/N: permission denied`, on a brand new Ubuntu release.**

The root cause was a hardening pass, in this case CIS style, that had changed the host's `runc` AppArmor profile from the distribution's default placeholder value of `flags=(unconfined)` to `flags=(complain)`. Modern versions of `runc` exec their own init process from a detached overlay mount, and AppArmor cannot resolve a path on a detached mount. It refuses the operation even in complain mode, and the kernel log rate limits the denial into invisibility, so nothing useful shows up anywhere obvious. If you apply any general purpose Linux hardening benchmark to a Docker host, exclude the item that forces every AppArmor profile into enforce or complain mode, or explicitly restore the stock profile for `runc`.

```bash
sudo mkdir -p /etc/apparmor.d/disable
sudo ln -s /etc/apparmor.d/runc /etc/apparmor.d/disable/runc
sudo apparmor_parser -R /etc/apparmor.d/runc

```

It is worth making a habit of running `docker run --rm hello-world` right after any hardening pass, before walking away from the box, just to confirm nothing quietly broke.

**A container refusing its own start up check, even with the right Linux capability already added.**

Some applications have their own internal check for whether they are allowed to bind a privileged port, and on newer kernel and container runtime combinations that check does not always correctly detect an ambient capability granted through `cap_add`. The application refuses to start as a non root user even though the capability is genuinely present. If a container that is supposed to run as a low privilege user with `NET_BIND_SERVICE` refuses to start with a permissions error, try removing the explicit non root `user:` override and letting it start as the image's default user, which is usually root, combined with `cap_drop: [ALL]` and only the one specific capability added back. You are still meaningfully sandboxed. You have simply stopped fighting a detection bug that is not yours to fix.

**A brand new container reproducibly failing to bind a port with "address already in use", even though nothing is actually listening on it.**

This one is not your firewall, and it is not another service quietly holding the port. It is Docker's own internal port allocator getting into a stuck state after a rapid sequence of failed container start attempts on the same published port. The fix is to remove the stuck container with `docker rm -f <container>` and redeploy. It is worth checking with `ss -tlnp` first, just to rule out a genuine conflict before assuming this is the cause.

**`systemd-resolved` interfering with a container that wants port 53.**

If you are also running a DNS filtering or ad blocking container, such as AdGuard Home or Pi-hole, on the same box, the stub listener that `systemd-resolved` runs can interfere with Docker's own port binding for port 53, even though that stub listener only ever binds to a loopback address. The fix is to disable it and give the host a static resolver instead.

```bash
sudo systemctl disable systemd-resolved --now
sudo rm -f /etc/resolv.conf
echo "nameserver 1.1.1.1" | sudo tee /etc/resolv.conf

```

## Hardening checklist

| Item                                                                                    | Why it matters                                                                                                     |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| SSH on a non default port, key only, no root login                                      | Removes the most common automated attack surface                                                                   |
| Admin ports kept outside the kernel's ephemeral range                                   | Avoids an intermittent and hard to diagnose bind failure                                                           |
| DOCKER-USER allow list with \--ctdir ORIGINAL                                           | Docker bypasses your normal firewall zones, so this is the actual enforcement point                                |
| Digest pinned images instead of :latest                                                 | Reproducible deploys, with no surprise upstream changes                                                            |
| cap\_drop: \[ALL\] plus only the capabilities each container actually documents needing | Standard defense in depth that costs nothing if the image already runs fine without more                           |
| read\_only: true with explicit tmpfs mounts for anything that genuinely needs to write  | Limits what a compromised container could persist or modify                                                        |
| gerbil.base\_endpoint set to the raw public IP, never a Cloudflare-proxied hostname     | WireGuard UDP is not proxied by Cloudflare, so a proxied name makes every site connector fail silently             |
| A scoped DNS API token, limited to one zone and to DNS editing only                     | Limits the blast radius if the token is ever exposed                                                               |
| The initial admin account created by a human, in a browser                              | Nobody else should ever know your own initial credentials, including any tooling that helped set the rest of it up |

## Closing thoughts

None of this is exotic. It is the same handful of Linux and Docker hardening habits applied consistently: least privilege, explicit firewall intent instead of implicit trust, and pinned versions everywhere it matters. The genuinely useful part of a guide like this is rarely the compose file itself. It is the list of things that actually went wrong, because that is usually the part nobody bothers to write down. Hopefully this saves someone else the couple of hours it took to work through all of it here.