# Can I self-host NordVPN?

**YES** — it's called wg-easy. ONE COMMAND setup · ~10 minutes to running · 512 MB RAM minimum · $12.99/mo you stop paying ($155.88/yr on the Basic plan).

wg-easy authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/nordvpn/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install wg-easy 15.3.0 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Say why when you ask: that hostname becomes `INIT_HOST` in step 3, the address every client
configuration this install hands out will dial. Its A record must already point at this server.

wg-easy needs 512 MB of RAM available and 5 GB free on /srv. The image publishes amd64 and
arm64. One requirement is not about size: WireGuard lives in the kernel, and the container
cannot create `wg0` if this kernel has no module for it.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
sudo modprobe wireguard
lsmod | grep -c '^wireguard'
```

If available RAM is under 512 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop. If the last command
prints `0`, stop and tell the user this kernel carries no WireGuard module: upstream names that
as the cause of `Cannot find device "wg0"`, and no container setting works around it. It
usually means an old kernel or a container-based VPS plan.

Make the module survive a reboot:

```bash
echo wireguard | sudo tee /etc/modules-load.d/wireguard.conf
```

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/wg-easy /srv/wg-easy/backups
sudo install -d -m 700 /srv/wg-easy/etc_wireguard
ls -la /srv/wg-easy
```

Assert: `ls -la` shows `backups` owned by the login user and `etc_wireguard` at mode `700`
owned by root. The published image declares no user, so the container runs as root and writes
that directory as root; leave it alone. Everything this service remembers is in there:
`wg-easy.db`, holding the admin account and every client's private key, and `wg0.conf`, which
the app rewrites from that database on every change.

## 3. Secrets

One secret: the password for the `admin` account the container creates on its first start.
Generate it on the server. Do not print it, do not repeat it in your summary, and do not put
it in a log line.

```bash
umask 077
cat > /srv/wg-easy/.env <<EOF
INIT_ENABLED=true
INIT_USERNAME=admin
INIT_PASSWORD=$(openssl rand -base64 24)
INIT_HOST=<DOMAIN>
INIT_PORT=51820
INIT_DNS=1.1.1.1
INIT_ALLOWED_IPS=0.0.0.0/0
EOF
chmod 600 /srv/wg-easy/.env
umask 022
ls -l /srv/wg-easy/.env
```

Assert: the file exists with mode `-rw-------`. Upstream documents this `INIT_` group as an
unattended setup read only during the container's first start, and recommends removing the
variables once it is done; step 7 removes them. The generated password is 32 characters, which
matters because upstream rejects any password under 12 and checks nothing else. `INIT_DNS` and
`INIT_ALLOWED_IPS` carry one value each because step 4 turns IPv6 off, and handing clients an
IPv6 resolver and a `::/0` route would push their IPv6 traffic into a tunnel with no IPv6
address on it.

Tell the user their password is in /srv/wg-easy/.env, read with
`sudo grep INIT_PASSWORD /srv/wg-easy/.env`, and that it belongs in their password manager
before step 7 runs, because step 7 deletes that line.

## 4. compose.yml

```bash
cat > /srv/wg-easy/compose.yml <<'EOF'
# wg-easy · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   getting started .... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/getting-started.md
#   basic installation . https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/examples/tutorials/basic-installation.md
#   optional config .... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/advanced/config/optional-config.md
#   unattended setup ... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/advanced/config/unattended-setup.md
#   caddy example ...... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/examples/tutorials/caddy.md
#
# One service and no database process. The admin UI, the wg0 interface and the
# SQLite file holding the account and every client key live in this container,
# and /etc/wireguard is the whole of the state.
#
# Two deliberate differences from the compose file upstream ships:
#   * SYS_MODULE is not granted and /lib/modules is not mounted. That
#     capability lets a container load kernel modules, which is a way out of
#     it; the install loads the wireguard module on the host instead.
#   * DISABLE_IPV6 is true. Upstream documents that as dropping the IPv6
#     firewall rules and the IPv6 address on the interface and on clients. It
#     takes the ip6tables kernel tables off the host's requirements and the
#     IPv6 Docker network out of this file. Tunnels here carry IPv4.
#
# Tag and digest read from ghcr.io on 2026-08-06; amd64 and arm64 published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wg-easy:
    image: ghcr.io/wg-easy/wg-easy:15.3.0@sha256:93bbd593e07bab98d02807a28770ac87ab6c48818e319e68c1f66561feb99876
    container_name: wg-easy
    restart: unless-stopped
    env_file: /srv/wg-easy/.env
    environment:
      # Upstream's default, kept explicit. The session cookie is marked Secure,
      # so the admin UI works over https and refuses a login over plain http.
      INSECURE: "false"
      DISABLE_IPV6: "true"
    volumes:
      - /srv/wg-easy/etc_wireguard:/etc/wireguard
    ports:
      # The tunnel. Phones and laptops speak WireGuard straight to this port,
      # so no reverse proxy can stand in front of it. It answers nothing at all
      # to a packet that is not signed by a key this install issued.
      - "51820:51820/udp"
      # The admin UI. Loopback only: the host's Caddy is the only thing that
      # reaches 8133.
      - "127.0.0.1:8133:51821"
    cap_add:
      # Creating wg0, writing its routes, and the NAT rule the PostUp hook adds.
      - NET_ADMIN
    sysctls:
      # Packets arriving on wg0 have to be routed out of eth0.
      - net.ipv4.ip_forward=1
      # wg-quick marks its own packets; without this the kernel drops them as
      # martians on the way back.
      - net.ipv4.conf.all.src_valid_mark=1
EOF
cd /srv/wg-easy && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The `sysctls` block is not decoration: without
`ip_forward` the tunnel comes up and forwards nothing, which is the failure that
looks like a DNS problem.

## 5. Caddy and TLS

Append the block below with `<DOMAIN>` replaced by the real hostname. Copy the file first: a
syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-wg-easy
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# wg-easy · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/examples/tutorials/caddy.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Caddy carries the
# admin UI and nothing else: the tunnel itself is UDP on 51820 and never passes
# through here.

<DOMAIN> {
	encode zstd gzip

	header {
		# The admin UI issues client keys, so a downgrade on one request is a
		# stolen tunnel. HSTS is not optional here.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8133 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8133
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-wg-easy, reload, and report what it objected to. Caddy requests the
certificate on the first request and renews it on its own. Nothing to schedule.

## 6. Firewall

Four ports, and the fourth is the one this install exists for:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 51820/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp carries the admin UI, 443/udp
is HTTP/3. 51820/udp is different in kind: WireGuard is the transport, phones send encrypted
UDP straight at it, and no reverse proxy can carry that. What sits there is a socket that stays
silent to every packet it cannot verify against a key this install issued, which is why
WireGuard belongs on a public port and the admin UI on 8133 does not.

Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp, 443/udp and 51820/udp,
and no rule for 8133. Tell the user one thing about that fourth rule: Docker writes its own
iptables rules for a published port ahead of ufw's, so deleting it would not close 51820/udp.
`docker compose down` does.

## 7. Start and verify

```bash
cd /srv/wg-easy
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS -H 'Accept-Language: en' https://<DOMAIN>/login | grep -o 'Sign In'
docker compose exec -T wg-easy wg show
```

Assert all three and print what you received. The loop ends printing `200`. The second prints
`Sign In`, the button on the only screen this app shows a stranger. The third prints a block
beginning `interface: wg0` with a `listening port: 51820` line, which is the tunnel existing
rather than the web server existing. If any of the three misses, stop, run
`docker compose logs --tail 40 wg-easy`, and name the likely step: `Cannot find device "wg0"`
is step 1's module check having been skipped, and a `502` from Caddy is step 5 pointing at a
port nothing is listening on. A running container is not success.

The first screen at https://<DOMAIN>/login is a card with `Username` and `Password` boxes and a
`Sign In` button. There is no register link and no default account.

STOP: tell the user to open https://<DOMAIN>, sign in as `admin` with the password from
`sudo grep INIT_PASSWORD /srv/wg-easy/.env`, save that password in their password manager, add
a client named `phone`, install the WireGuard app on that phone, scan the QR code the page
shows, and switch the tunnel on. Wait. Do not continue until they confirm the phone says it is
connected. Only a real device proves this end to end.

Once they confirm, prove the handshake, then take the password out of the environment:

```bash
cd /srv/wg-easy
docker compose exec -T wg-easy wg show | grep -c 'latest handshake'
sed -i '/^INIT_/d' /srv/wg-easy/.env
docker compose up -d --force-recreate
sleep 15
sudo grep -c '^INIT_' /srv/wg-easy/.env || true
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/login
```

Assert all three before you report success. The first prints `1` or more, the phone and the
server having agreed keys and moved packets. The third prints `0`, so the password no longer
sits in a file the container reads on every start, which is what upstream recommends once the
setup is done. The last prints `200`: the account and the clients live in the database now, so
losing those variables changes nothing.

## 8. First backup and restore

One archive. The database, the interface config, the compose file and the Caddy site block are
the whole install, and together a few hundred kilobytes.

```bash
cd /srv/wg-easy
docker compose down
sudo tar -czf /srv/wg-easy/backups/wg-easy-$(date +%F).tar.gz -C /srv/wg-easy compose.yml .env etc_wireguard -C /etc/caddy Caddyfile
docker compose up -d
ls -lh /srv/wg-easy/backups/
```

Assert: the archive exists and is non-empty. Print its size. The container goes down for the
copy on purpose: `wg-easy.db` is SQLite and a file copied mid-write is not a database. The
tunnel is gone for about twenty seconds and every client reconnects on its own.

A backup on the same disk is not a backup. Run this from the user's machine:

```bash
mkdir -p ~/backups/wg-easy
scp vps:/srv/wg-easy/backups/*.tar.gz ~/backups/wg-easy/
```

To restore: `docker compose down`, `sudo rm -rf /srv/wg-easy/etc_wireguard`,
`sudo tar -xzf <archive> -C /srv/wg-easy compose.yml .env etc_wireguard`, then
`docker compose up -d`. The Caddy site block is in the same archive under `Caddyfile` if
/etc/caddy has to be rebuilt. Say the stakes plainly: that database holds the private key of
every device the user has enrolled, so a leaked archive is every tunnel opened, and a missing
one means re-enrolling every phone by hand.

## 9. Updating later

New versions are listed at https://github.com/wg-easy/wg-easy/releases. Back up first, then
edit the image line in /srv/wg-easy/compose.yml to the new tag and its digest:

```bash
cd /srv/wg-easy
docker compose pull
docker compose up -d
docker compose logs --tail 30 wg-easy
```

Watch that log until it settles, then re-run the three checks from step 7. Upstream publishes a
moving `15` tag this file does not use; a major-version jump has migrated the database before,
so read the notes before changing the first number.

## 10. What will probably go wrong

The interface will come up, the phone will say `Connected`, and no page will load on it. I lost
twenty minutes to that. The tunnel was fine and nothing was being forwarded out of the box,
because a WireGuard peer that cannot route still reports a healthy handshake, and the symptom
reads exactly like a DNS fault. Check in this order: `docker compose exec -T wg-easy wg show`
for a recent handshake, then `docker compose exec -T wg-easy sysctl net.ipv4.ip_forward`, then
`docker compose exec -T wg-easy iptables -t nat -L POSTROUTING -n` for the MASQUERADE line the
PostUp hook writes.

## 11. Out of scope

- Do not enable the per-client firewall in the admin panel. Upstream marks it experimental and
  it needs host iptables rules this install does not create.
- Do not install AdGuard Home or Pi-hole alongside this. Pointing the tunnel's DNS at a resolver
  on the same box is a second install with its own prompt, not a setting in this one.
- Do not enable the Prometheus metrics endpoint. It is off by default and turning it on adds a
  route with no authentication unless a bearer password is set as well.
- Do not set `EXPERIMENTAL_AWG`. That trades a standard WireGuard tunnel every client app
  already speaks for an obfuscated one.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing wg-easy 15.3.0 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box.

Read this before step 1. `<DOMAIN>` becomes `INIT_HOST`, the address written into every client
configuration this server hands out. Changing it later means editing the Host in the admin panel
and reissuing every config you have already put on a phone, so pick the hostname you intend to
keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
sudo modprobe wireguard
lsmod | grep -c '^wireguard'
```

You should see: at least `512` MB available, at least `5` G free, `amd64` or `arm64`, your
server's IP, and `1` on the last line.

If you do not: an empty `dig` line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a hostname that does
not resolve and failed attempts count against a rate limit you cannot see. A `0` on the last
line is the one that ends this install: WireGuard lives in the kernel, and upstream names a
missing module as the cause of `Cannot find device "wg0"`. No container setting works around it.
On a normal VPS the module is there; on an old kernel or a container-based plan it is not, and
the honest answer is a different server.

Make the module survive a reboot:

```bash
echo wireguard | sudo tee /etc/modules-load.d/wireguard.conf
```

You should see: `wireguard` echoed back, because `tee` prints what it writes.

If you do not: a permission error means the `sudo` was dropped from the line.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/wg-easy /srv/wg-easy/backups
sudo install -d -m 700 /srv/wg-easy/etc_wireguard
ls -la /srv/wg-easy
```

You should see: `backups` owned by you, and `etc_wireguard` at mode `drwx------` owned by root.

If you do not: leave `etc_wireguard` owned by root on purpose. The published image declares no
user, so the container runs as root and writes there as root. That directory is the entire
install: `wg-easy.db` holds the admin account and every client's private key, and `wg0.conf` is
rewritten from that database on every change.

## 3. Secrets

One secret: the password for the `admin` account the container creates on its first start. It is
generated here, on the server, and goes straight into a file only you can read.

```bash
umask 077
cat > /srv/wg-easy/.env <<EOF
INIT_ENABLED=true
INIT_USERNAME=admin
INIT_PASSWORD=$(openssl rand -base64 24)
INIT_HOST=<DOMAIN>
INIT_PORT=51820
INIT_DNS=1.1.1.1
INIT_ALLOWED_IPS=0.0.0.0/0
EOF
chmod 600 /srv/wg-easy/.env
umask 022
ls -l /srv/wg-easy/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the `INIT_HOST` line with your real hostname before you paste.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/wg-easy/.env` and carry
on. If the file already existed from an earlier attempt, this block has overwritten the
password, which is harmless before the first container start and useless after it: once the
account exists in the database, upstream ignores `INIT_` entirely and the way to change a
password is `docker compose exec -it wg-easy cli db:admin:reset`.

Read the password once with `sudo grep INIT_PASSWORD /srv/wg-easy/.env` and put it in your
password manager now. Step 7 deletes that line, which is what upstream recommends once the setup
has run, and after that the file no longer has it.

Do not paste that file, the password, or any command output containing it into this chat window.
The agent path never sees those values; a chat window hands them to a third party.

`INIT_DNS` and `INIT_ALLOWED_IPS` carry one value each because step 4 turns IPv6 off, and a
client handed an IPv6 resolver and a `::/0` route would push its IPv6 traffic into a tunnel with
no IPv6 address on it.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/wg-easy/compose.yml <<'EOF'
# wg-easy · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   getting started .... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/getting-started.md
#   basic installation . https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/examples/tutorials/basic-installation.md
#   optional config .... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/advanced/config/optional-config.md
#   unattended setup ... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/advanced/config/unattended-setup.md
#   caddy example ...... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/examples/tutorials/caddy.md
#
# One service and no database process. The admin UI, the wg0 interface and the
# SQLite file holding the account and every client key live in this container,
# and /etc/wireguard is the whole of the state.
#
# Two deliberate differences from the compose file upstream ships:
#   * SYS_MODULE is not granted and /lib/modules is not mounted. That
#     capability lets a container load kernel modules, which is a way out of
#     it; the install loads the wireguard module on the host instead.
#   * DISABLE_IPV6 is true. Upstream documents that as dropping the IPv6
#     firewall rules and the IPv6 address on the interface and on clients. It
#     takes the ip6tables kernel tables off the host's requirements and the
#     IPv6 Docker network out of this file. Tunnels here carry IPv4.
#
# Tag and digest read from ghcr.io on 2026-08-06; amd64 and arm64 published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wg-easy:
    image: ghcr.io/wg-easy/wg-easy:15.3.0@sha256:93bbd593e07bab98d02807a28770ac87ab6c48818e319e68c1f66561feb99876
    container_name: wg-easy
    restart: unless-stopped
    env_file: /srv/wg-easy/.env
    environment:
      # Upstream's default, kept explicit. The session cookie is marked Secure,
      # so the admin UI works over https and refuses a login over plain http.
      INSECURE: "false"
      DISABLE_IPV6: "true"
    volumes:
      - /srv/wg-easy/etc_wireguard:/etc/wireguard
    ports:
      # The tunnel. Phones and laptops speak WireGuard straight to this port,
      # so no reverse proxy can stand in front of it. It answers nothing at all
      # to a packet that is not signed by a key this install issued.
      - "51820:51820/udp"
      # The admin UI. Loopback only: the host's Caddy is the only thing that
      # reaches 8133.
      - "127.0.0.1:8133:51821"
    cap_add:
      # Creating wg0, writing its routes, and the NAT rule the PostUp hook adds.
      - NET_ADMIN
    sysctls:
      # Packets arriving on wg0 have to be routed out of eth0.
      - net.ipv4.ip_forward=1
      # wg-quick marks its own packets; without this the kernel drops them as
      # martians on the way back.
      - net.ipv4.conf.all.src_valid_mark=1
EOF
cd /srv/wg-easy && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/wg-easy/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal,
so run `rm /srv/wg-easy/compose.yml` and paste again in one go. The `sysctls` block is not
decoration: without `ip_forward` the tunnel comes up and forwards nothing, which is the failure
that looks like a DNS problem.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-wg-easy
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# wg-easy · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/examples/tutorials/caddy.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Caddy carries the
# admin UI and nothing else: the tunnel itself is UDP on 51820 and never passes
# through here.

<DOMAIN> {
	encode zstd gzip

	header {
		# The admin UI issues client keys, so a downgrade on one request is a
		# stolen tunnel. HSTS is not optional here.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8133 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8133
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-wg-easy /etc/caddy/Caddyfile`, reload,
and paste again. Caddy terminates TLS and speaks plain http to the container, which is why
`INSECURE` stays `false` in the compose file: the session cookie is marked Secure, and the
sign-in page refuses a password typed over plain http.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 51820/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp`, `443/udp` and `51820/udp`, and
no rule mentioning `8133`.

If you do not: delete anything for `8133` with `sudo ufw delete allow 8133`. It is bound to
127.0.0.1 by the compose file, so a firewall rule for it would only widen what Caddy already
fronts. 80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp carries the admin UI,
443/udp is HTTP/3. 51820/udp is different in kind: WireGuard is the transport, phones send
encrypted UDP straight at it, and no reverse proxy can carry that. What sits on that port is a
socket that stays silent to every packet it cannot verify against a key this install issued,
which is why WireGuard belongs on a public port and the admin UI does not. One thing to know
about that fourth rule: Docker writes its own iptables rules for a published port ahead of
ufw's, so deleting it would not close 51820/udp. `docker compose down` does. `Status: inactive`
is a different problem: Prompt Zero left this firewall enabled, so something has turned it off
since, and `sudo ufw enable` puts it back before you go further.

## 7. Start and verify

```bash
cd /srv/wg-easy
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS -H 'Accept-Language: en' https://<DOMAIN>/login | grep -o 'Sign In'
docker compose exec -T wg-easy wg show
```

You should see, in order: the loop reaching `200`, the word `Sign In`, then a block beginning
`interface: wg0` with a `listening port: 51820` line.

If you do not: `Cannot find device "wg0"` in the logs means step 1's module check was skipped or
the module was unloaded, so run `sudo modprobe wireguard` and `docker compose up -d` again. A
`502` from Caddy means nothing is listening on 8133: check `docker compose ps` and
`docker compose logs --tail 40 wg-easy`. If the loop never reaches `200` but the container is
running, the certificate is probably still being issued; give it another minute. A running
container is not success, and that third command is why: the web server can be perfectly healthy
while the tunnel does not exist.

The first screen at https://<DOMAIN>/login is a card with `Username` and `Password` boxes and a
`Sign In` button. There is no register link and no default account.

Now open https://<DOMAIN> in a browser, sign in as `admin` with the password from
`sudo grep INIT_PASSWORD /srv/wg-easy/.env`, and save that password in your password manager.
Then add a client named `phone`, install the WireGuard app on that phone, scan the QR code the
page shows, and switch the tunnel on.

Once the phone says it is connected, prove the handshake and clear the password out of the
environment:

```bash
cd /srv/wg-easy
docker compose exec -T wg-easy wg show | grep -c 'latest handshake'
sed -i '/^INIT_/d' /srv/wg-easy/.env
docker compose up -d --force-recreate
sleep 15
sudo grep -c '^INIT_' /srv/wg-easy/.env || true
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/login
```

You should see: `1` or more, then `0`, then `200`.

If you do not: a `0` from the first command means the phone never completed a handshake. Check
that the tunnel switch is actually on, then that `sudo ufw status verbose` really lists
51820/udp, then whether your hosting provider runs a firewall of its own in front of the box,
because several do and UDP is what gets forgotten there. The `0` from the third command is the
one you want: the password is no longer in a file the container reads on every start, which is
what upstream recommends once the setup is done, and the final `200` proves the account and the
clients live in the database rather than in those variables.

## 8. First backup and restore

One archive. The database, the interface config, the compose file and the Caddy site block are
the whole install, and together a few hundred kilobytes.

```bash
cd /srv/wg-easy
docker compose down
sudo tar -czf /srv/wg-easy/backups/wg-easy-$(date +%F).tar.gz -C /srv/wg-easy compose.yml .env etc_wireguard -C /etc/caddy Caddyfile
docker compose up -d
ls -lh /srv/wg-easy/backups/
```

You should see: one file, a few hundred kilobytes on a fresh install. The container is down for
about twenty seconds, on purpose, because `wg-easy.db` is SQLite and a file copied mid-write is
not a database. Connected clients reconnect on their own.

If you do not: a `.tar.gz` of about 100 bytes means the archive is empty, so check that
`/srv/wg-easy/etc_wireguard` has files in it. A `Permission denied` means the `sudo` was dropped
from the `tar` line; that directory belongs to root.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/wg-easy
scp vps:/srv/wg-easy/backups/*.tar.gz ~/backups/wg-easy/
```

You should see: one file copied, and it listed by `ls -lh ~/backups/wg-easy/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is one test client:

```bash
cd /srv/wg-easy
docker compose down
sudo rm -rf /srv/wg-easy/etc_wireguard
sudo tar -xzf /srv/wg-easy/backups/wg-easy-$(date +%F).tar.gz -C /srv/wg-easy compose.yml .env etc_wireguard
docker compose up -d
sleep 20
docker compose exec -T wg-easy wg show | grep -c '^peer:'
```

You should see: `1`, the client you enrolled coming back out of the archive and into the live
interface.

If you do not: `0` peers means the archive did not contain the database, so check the archive
listing with `tar -tzf` before trusting it. Understand the stakes before you skip this: that
database holds the private key of every device you have enrolled, so an archive that leaks is
every tunnel opened, and an archive that is missing means re-enrolling every phone by hand.

## 9. Updating later

New versions are listed at https://github.com/wg-easy/wg-easy/releases. Take the backup first,
then edit the `image:` line in /srv/wg-easy/compose.yml to the new tag and its digest.

```bash
cd /srv/wg-easy
docker compose pull
docker compose up -d
docker compose logs --tail 30 wg-easy
```

You should see: migration lines, then the banner with the version number, and no repeating
restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
three checks from step 7 before you call the update done, including `wg show`, because the web
interface can come back perfectly while the interface fails to start. Upstream also publishes a
moving `15` tag that this file deliberately does not use; a major-version jump has migrated the
database before, so read the release notes before changing the first number.

## 10. What will probably go wrong

The web interface will come up, the phone will say `Connected`, and no page will load on it. I
lost twenty minutes to that. The tunnel was fine and nothing was being forwarded out of the box,
because a WireGuard peer that cannot route still reports a healthy handshake, and the symptom
reads exactly like a DNS fault. Check in this order: `docker compose exec -T wg-easy wg show`
for a recent handshake, then `docker compose exec -T wg-easy sysctl net.ipv4.ip_forward`, then
`docker compose exec -T wg-easy iptables -t nat -L POSTROUTING -n` for the MASQUERADE line the
PostUp hook writes.

## 11. Out of scope

- Do not enable the per-client firewall in the admin panel. Upstream marks it experimental and
  it needs host iptables rules this install does not create.
- Do not install AdGuard Home or Pi-hole alongside this. Pointing the tunnel's DNS at a resolver
  on the same box is a second install with its own prompt, not a setting in this one.
- Do not enable the Prometheus metrics endpoint. It is off by default and turning it on adds a
  route with no authentication unless a bearer password is set as well.
- Do not set `EXPERIMENTAL_AWG`. That trades a standard WireGuard tunnel every client app
  already speaks for an obfuscated one.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install wg-easy 15.3.0 under ~/selfhost/wg-easy, with its admin interface at
http://localhost:8133 and its tunnel on UDP 51820.

## 1. Preflight

Say this before step 2 runs; it decides whether they want this install at all. Every client
configuration this creates carries `Endpoint = localhost:51820`, which means "the machine
reading this file" wherever it is read, so a config copied to a phone points that phone at
itself. This is a WireGuard server for one computer, not a VPN the household joins.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
ID and codename print next, for step 2. wg-easy needs 512 MB of RAM available and 5 GB free on
the home disk; the image publishes amd64 and arm64. Under either floor, print both numbers and
stop.

WireGuard lives in the kernel and the container cannot create `wg0` without it. On Linux that
kernel is this machine's, so check it:

```bash
if [ "$(uname -s)" = "Linux" ]; then
  sudo modprobe wireguard
  lsmod | grep -c '^wireguard'
fi
```

On Linux a `0` there is a stop: this kernel has no WireGuard module and no container setting
works around it. On macOS and Windows the kernel belongs to Docker Desktop's virtual machine
rather than this operating system, so there is nothing here to load, and step 7 is where the
answer arrives as `Cannot find device "wg0"` if Docker Desktop is too old.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo 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/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/wg-easy/etc_wireguard ~/selfhost/wg-easy/backups
ls -la ~/selfhost/wg-easy
```

Assert: `ls -la` shows `etc_wireguard` and `backups`, both owned by the user. No ownership fix
runs here. The image declares no user, so the container writes as root: on Linux the files under
`etc_wireguard` end up root's, and on macOS and Windows Docker Desktop maps them back, which is
why step 8 archives from inside the container.

## 4. Secrets

One secret: the password for the `admin` account the container creates on its first start.
Generate it here, print it nowhere, keep it out of your summary and any log line.

```bash
umask 077
cat > ~/selfhost/wg-easy/.env <<EOF
INIT_ENABLED=true
INIT_USERNAME=admin
INIT_PASSWORD=$(openssl rand -base64 24)
INIT_HOST=localhost
INIT_PORT=51820
INIT_DNS=1.1.1.1
INIT_ALLOWED_IPS=0.0.0.0/0
EOF
chmod 600 ~/selfhost/wg-easy/.env
umask 022
ls -l ~/selfhost/wg-easy/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so this runs the same on
all three. Upstream documents the `INIT_` group as an unattended setup read only at the
container's first start and recommends removing the variables afterwards; step 7 does. The
password is 32 characters because upstream rejects anything under 12, and `INIT_DNS` and
`INIT_ALLOWED_IPS` carry one value each because step 5 turns IPv6 off.

On Windows those mode bits are advisory: NTFS does not enforce them, and the real boundary is
the user's own Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/wg-easy/compose.yml <<'EOF'
# wg-easy · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   optional config .... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/advanced/config/optional-config.md
#   unattended setup ... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/advanced/config/unattended-setup.md
#   no reverse proxy ... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/examples/tutorials/reverse-proxyless.md
#
# One service. Paths are relative to ~/selfhost/wg-easy/ and both mounts are
# ordinary bind mounts, so the database and the archives show up in Finder or
# Explorer. Two differences from the VPS file: INSECURE is true, upstream's
# setting for running without a reverse proxy, and ./backups is mounted so
# step 8 writes its archive from inside the container.
#
# Same tag and digest as the VPS file, read from ghcr.io on 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wg-easy:
    image: ghcr.io/wg-easy/wg-easy:15.3.0@sha256:93bbd593e07bab98d02807a28770ac87ab6c48818e319e68c1f66561feb99876
    container_name: wg-easy
    restart: unless-stopped
    env_file: ./.env
    environment:
      # Nothing terminates TLS here, so the session cookie cannot be Secure.
      INSECURE: "true"
      DISABLE_IPV6: "true"
    volumes:
      - ./etc_wireguard:/etc/wireguard
      - ./backups:/backups
    ports:
      # The tunnel: the one port here that is not on loopback. A WireGuard
      # listener answers nothing to a packet it cannot verify.
      - "51820:51820/udp"
      # The admin UI. Loopback only: no other device on the wifi reaches 8133.
      - "127.0.0.1:8133:51821"
    cap_add:
      # Creating wg0, its routes, and the NAT rule the PostUp hook adds.
      - NET_ADMIN
    sysctls:
      # Routing out of eth0, and the mark wg-quick sets on its own packets so
      # the kernel does not drop them as martians on the way back.
      - net.ipv4.ip_forward=1
      - net.ipv4.conf.all.src_valid_mark=1
EOF
cd ~/selfhost/wg-easy && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context, so the sign-in page works without one.

8133 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi. One port is not on loopback and the user should hear it plainly. 51820/udp, the tunnel, is
published as it is on a server, because a WireGuard server with no reachable socket is a key
generator. A stranger on that network gets nothing from it: WireGuard answers no packet it
cannot verify, and `docker compose down` closes it.

```bash
grep -n '127.0.0.1' ~/selfhost/wg-easy/compose.yml
```

Assert: one line, `- "127.0.0.1:8133:51821"`.

## 7. Start and verify

```bash
cd ~/selfhost/wg-easy
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8133/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS -H 'Accept-Language: en' http://localhost:8133/login | grep -o 'Sign In'
docker compose exec -T wg-easy wg show
```

Assert all three and print what you received. The loop ends printing `200`. The second prints
`Sign In`. The third prints a block beginning `interface: wg0` with a `listening port: 51820`
line, the tunnel existing rather than the web server. If any miss, stop, run
`docker compose logs --tail 40 wg-easy`, and name the cause: `Cannot find device "wg0"` is the
kernel module from step 1; `port is already allocated` means something else holds 8133 or 51820,
so find it with `lsof -nP -iTCP:8133 -sTCP:LISTEN` or, on Windows,
`netstat -ano | findstr :8133`. A running container is not success.

The first screen at http://localhost:8133/login is a card with `Username` and `Password` boxes
and a `Sign In` button. There is no register link and no default account.

STOP: tell the user to open http://localhost:8133, sign in as `admin` with the password from
`grep INIT_PASSWORD ~/selfhost/wg-easy/.env`, save it in their password manager, and add a
client named `laptop`. Wait. Do not continue until they confirm the client is listed.

Once they confirm, prove the key reached the live interface, then clear the password out of the
environment:

```bash
cd ~/selfhost/wg-easy
docker compose exec -T wg-easy wg show | grep -c '^peer:'
sed -i.bak '/^INIT_/d' .env && rm -f .env.bak
docker compose up -d --force-recreate
sleep 15
grep -c '^INIT_' .env || true
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8133/login
```

Assert all four before reporting success. The first prints `1`, a key that went from the browser
into the running kernel interface. The third prints `0`, so the password is out of the file the
container reads on every start, which upstream recommends once setup is done. The last prints
`200`: the account and the client live in the database now.

Say what this cannot prove rather than implying it passed: a handshake needs a device dialling
in, and the only one that can reach `localhost:51820` is this computer.

## 8. First backup and restore

One archive, written from inside the container so one command covers all three systems, with
the app down because `wg-easy.db` is SQLite and a mid-write copy is not a database.

```bash
cd ~/selfhost/wg-easy
docker compose down
docker compose run --rm --no-deps -T wg-easy sh -c 'tar -czf /backups/wg-easy-$(date +%F).tar.gz -C /etc/wireguard .'
docker compose up -d
ls -lh ~/selfhost/wg-easy/backups/
```

Assert: the archive exists and is non-empty. Print its size. It holds the database with the
admin account and every client's private key.

That archive is on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a
folder their sync service watches or a USB stick, and copy it there with `cp`. In Git Bash a
Windows drive is written `/d/Backups`. Assert: the user confirms the filename is listed there.
If they have nowhere to put it, say plainly that this install has no backup, and say the other
half: whoever holds that file holds the private key of every device enrolled here, so it belongs
somewhere encrypted.

To restore: `docker compose down`, `rm -rf etc_wireguard`, `mkdir etc_wireguard`, untar into it
with
`docker compose run --rm --no-deps -T wg-easy sh -c 'tar -xzf /backups/<archive> -C /etc/wireguard'`,
then `docker compose up -d`. If the folder is gone too, steps 4 and 5 rewrite compose.yml and
`.env`, and `cli db:admin:reset` sets a new password.

## 9. Updating later

New versions are listed at https://github.com/wg-easy/wg-easy/releases. Back up first, then set
the image line in compose.yml to the new tag and digest:

```bash
cd ~/selfhost/wg-easy
docker compose pull
docker compose up -d
docker compose logs --tail 30 wg-easy
```

Watch that log until it settles, then re-run step 7's three checks. Upstream also publishes a
moving `15` tag, which this file does not use.

## 10. What will probably go wrong

I closed the laptop lid, opened it an hour later, and found the admin page dead and the tunnel
gone. Nothing was broken. A sleeping machine is not a VPN server, and Docker Desktop does not
always come back with the session either. Turn on its start-at-login setting, and after a reboot
or a long sleep run `cd ~/selfhost/wg-easy && docker compose up -d` before concluding anything
is wrong. This is the part a rented server is selling: a machine that is awake when your phone
reaches for it.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not change the Host in the admin panel to this machine's address on the wifi so a phone can
  join. That address moves with the network, and handing tunnel keys to a second device raises a
  security question this prompt has not answered.
- Do not enable the per-client firewall in the admin panel. Upstream marks it experimental and
  it needs host iptables rules this install does not create.
- Do not install AdGuard Home or Pi-hole alongside this. A local resolver for the tunnel's DNS
  is a separate install.
````

## docker-compose.yml

```yaml
# wg-easy · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   getting started .... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/getting-started.md
#   basic installation . https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/examples/tutorials/basic-installation.md
#   optional config .... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/advanced/config/optional-config.md
#   unattended setup ... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/advanced/config/unattended-setup.md
#   caddy example ...... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/examples/tutorials/caddy.md
#
# One service and no database process. The admin UI, the wg0 interface and the
# SQLite file holding the account and every client key live in this container,
# and /etc/wireguard is the whole of the state.
#
# Two deliberate differences from the compose file upstream ships:
#   * SYS_MODULE is not granted and /lib/modules is not mounted. That
#     capability lets a container load kernel modules, which is a way out of
#     it; the install loads the wireguard module on the host instead.
#   * DISABLE_IPV6 is true. Upstream documents that as dropping the IPv6
#     firewall rules and the IPv6 address on the interface and on clients. It
#     takes the ip6tables kernel tables off the host's requirements and the
#     IPv6 Docker network out of this file. Tunnels here carry IPv4.
#
# Tag and digest read from ghcr.io on 2026-08-06; amd64 and arm64 published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wg-easy:
    image: ghcr.io/wg-easy/wg-easy:15.3.0@sha256:93bbd593e07bab98d02807a28770ac87ab6c48818e319e68c1f66561feb99876
    container_name: wg-easy
    restart: unless-stopped
    env_file: /srv/wg-easy/.env
    environment:
      # Upstream's default, kept explicit. The session cookie is marked Secure,
      # so the admin UI works over https and refuses a login over plain http.
      INSECURE: "false"
      DISABLE_IPV6: "true"
    volumes:
      - /srv/wg-easy/etc_wireguard:/etc/wireguard
    ports:
      # The tunnel. Phones and laptops speak WireGuard straight to this port,
      # so no reverse proxy can stand in front of it. It answers nothing at all
      # to a packet that is not signed by a key this install issued.
      - "51820:51820/udp"
      # The admin UI. Loopback only: the host's Caddy is the only thing that
      # reaches 8133.
      - "127.0.0.1:8133:51821"
    cap_add:
      # Creating wg0, writing its routes, and the NAT rule the PostUp hook adds.
      - NET_ADMIN
    sysctls:
      # Packets arriving on wg0 have to be routed out of eth0.
      - net.ipv4.ip_forward=1
      # wg-quick marks its own packets; without this the kernel drops them as
      # martians on the way back.
      - net.ipv4.conf.all.src_valid_mark=1
```

## compose.local.yml

```yaml
# wg-easy · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   optional config .... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/advanced/config/optional-config.md
#   unattended setup ... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/advanced/config/unattended-setup.md
#   no reverse proxy ... https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/examples/tutorials/reverse-proxyless.md
#
# One service. Paths are relative to ~/selfhost/wg-easy/ and both mounts are
# ordinary bind mounts, so the database and the archives show up in Finder or
# Explorer. Two differences from the VPS file: INSECURE is true, upstream's
# setting for running without a reverse proxy, and ./backups is mounted so
# step 8 writes its archive from inside the container.
#
# Same tag and digest as the VPS file, read from ghcr.io on 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wg-easy:
    image: ghcr.io/wg-easy/wg-easy:15.3.0@sha256:93bbd593e07bab98d02807a28770ac87ab6c48818e319e68c1f66561feb99876
    container_name: wg-easy
    restart: unless-stopped
    env_file: ./.env
    environment:
      # Nothing terminates TLS here, so the session cookie cannot be Secure.
      INSECURE: "true"
      DISABLE_IPV6: "true"
    volumes:
      - ./etc_wireguard:/etc/wireguard
      - ./backups:/backups
    ports:
      # The tunnel: the one port here that is not on loopback. A WireGuard
      # listener answers nothing to a packet it cannot verify.
      - "51820:51820/udp"
      # The admin UI. Loopback only: no other device on the wifi reaches 8133.
      - "127.0.0.1:8133:51821"
    cap_add:
      # Creating wg0, its routes, and the NAT rule the PostUp hook adds.
      - NET_ADMIN
    sysctls:
      # Routing out of eth0, and the mark wg-quick sets on its own packets so
      # the kernel does not drop them as martians on the way back.
      - net.ipv4.ip_forward=1
      - net.ipv4.conf.all.src_valid_mark=1
```

## Caddyfile

```text
# wg-easy · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/examples/tutorials/caddy.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Caddy carries the
# admin UI and nothing else: the tunnel itself is UDP on 51820 and never passes
# through here.

<DOMAIN> {
	encode zstd gzip

	header {
		# The admin UI issues client keys, so a downgrade on one request is a
		# stolen tunnel. HSTS is not optional here.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8133 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8133
}
```

## install.sh

```bash
#!/usr/bin/env bash
# wg-easy · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=vpn.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/getting-started.md
#   https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/examples/tutorials/basic-installation.md
#   https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/advanced/config/optional-config.md
#   https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/advanced/config/unattended-setup.md
#   https://github.com/wg-easy/wg-easy/blob/v15.3.0/docs/content/faq.md
#
# One secret is generated here, on this machine: the password for the admin
# account the container creates on its first start. It goes into
# /srv/wg-easy/.env with mode 600 and is never printed.
#
# DOMAIN_HOST is also INIT_HOST, the address every client configuration this
# server hands out will dial. Choose it once.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/wg-easy}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. vpn.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 512 ] || die "only ${avail_mb} MB of RAM available; this install wants 512 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# WireGuard is a kernel module. Upstream names a missing one as the cause of
# 'Cannot find device "wg0"', and no container setting works around it.
sudo modprobe wireguard 2>/dev/null || true
[ "$(lsmod | grep -c '^wireguard')" -ge 1 ] \
	|| die "this kernel has no WireGuard module. That is usually an old kernel or a container-based VPS plan."
echo wireguard | sudo tee /etc/modules-load.d/wireguard.conf >/dev/null

# --- 2. Lay the files out ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/etc_wireguard"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the one secret, on the server -------------------------------
#
# Upstream reads the INIT_ group only during the container's first start and
# recommends removing the variables afterwards; section 7 below does that.
# Read the password before then with
#   sudo grep INIT_PASSWORD /srv/wg-easy/.env
# INIT_DNS and INIT_ALLOWED_IPS carry one value each because DISABLE_IPV6 is
# true in compose.yml, so a client must not be handed an IPv6 route.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		INIT_ENABLED=true
		INIT_USERNAME=admin
		INIT_PASSWORD=$(openssl rand -base64 24)
		INIT_HOST=${DOMAIN_HOST}
		INIT_PORT=51820
		INIT_DNS=1.1.1.1
		INIT_ALLOWED_IPS=0.0.0.0/0
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-wg-easy"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: four open, and 8133 is not one of them ------------------------
#
# 51820/udp is the tunnel itself. Browsers and phones speak WireGuard straight
# to it and no reverse proxy can carry that, so it is published to the world.
# The socket stays silent to any packet it cannot verify against a key this
# install issued.

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3, 51820/udp for WireGuard; 8133 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw allow 51820/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/login"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/login" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/login answered ${code:-nothing}. Check: docker compose logs --tail 40 wg-easy"

curl -sS -H 'Accept-Language: en' "https://${DOMAIN_HOST}/login" | grep -q 'Sign In' \
	|| die "/login answered 200 without the Sign In form. Check: docker compose logs --tail 40 wg-easy"

# The tunnel has to exist, not only the web server. This is the container's own
# health check, run once here so a failure stops the script.
docker compose exec -T wg-easy wg show | grep -q '^interface: wg0' \
	|| die "wg show printed no wg0 interface. Check: docker compose logs --tail 40 wg-easy"
docker compose exec -T wg-easy wg show | grep -q 'listening port: 51820' \
	|| die "wg0 exists but is not listening on 51820. Stop and investigate."

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose down
sudo tar -czf "$APP_DIR/backups/wg-easy-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env etc_wireguard -C /etc/caddy Caddyfile
docker compose up -d
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/wg-easy-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	wg-easy is answering at https://${DOMAIN_HOST}/login

	  1. Sign in as admin. Your password is in $APP_DIR/.env, mode 600:
	       sudo grep INIT_PASSWORD $APP_DIR/.env
	     Put it in your password manager now. It was not printed here.
	  2. Then delete those lines, which is what upstream recommends once the
	     setup has run:
	       sed -i '/^INIT_/d' $APP_DIR/.env
	       cd $APP_DIR && docker compose up -d --force-recreate
	     The account and the clients are in the database from here on. A
	     forgotten password is reset with
	       docker compose exec -it wg-easy cli db:admin:reset
	  3. Add your first client in the web interface and scan its QR code with
	     the WireGuard app. Confirm the tunnel end to end with
	       docker compose exec -T wg-easy wg show
	     which grows a "latest handshake" line once a device connects. A
	     running container is not success.
	  4. First backup written to $APP_DIR/backups. It holds the private key of
	     every device you enrol, so it is on the same disk as the data, which is
	     not a backup, and it belongs somewhere encrypted. Copy it off tonight.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/nordvpn/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
