Full server setup: Ubuntu 22.04 / 24.04 + AmneziaWG kernel module + Docker + Caddy reverse proxy.
| OS | Ubuntu 22.04 LTS or Ubuntu 24.04 LTS |
| Kernel | 6.1+ (see Step 1) |
| RAM | 512 MB minimum |
| Access | Root |
| Network | Public IP, ports 443/TCP and WireGuard UDP ports open |
Ubuntu 24.04: skip this step — ships with kernel 6.8 by default.
Ubuntu 22.04 ships with kernel 5.15. The AmneziaWG DKMS module requires ≥ 6.1
(timer_delete symbol). Install the HWE kernel:
apt update && apt install -y linux-generic-hwe-22.04
rebootAfter reboot verify:
uname -r
# expected: 6.8.x-xx-genericadd-apt-repository ppa:amnezia/ppa
apt install -y amneziawgLoad the module immediately and register it for autoload on boot:
modprobe amneziawg
echo "amneziawg" | tee /etc/modules-load.d/amneziawg.confVerify:
lsmod | grep amneziawg
# expected: amneziawg 131072 0curl -fsSL https://get.docker.com | shEnable IP forwarding and tune network buffers (required for WireGuard routing and HTTP/3):
cat > /etc/sysctl.d/99-cascade.conf << 'EOF'
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
net.core.rmem_max = 7340032
net.core.wmem_max = 7340032
EOF
sysctl --systemMinimal/stripped-down VPS images often don't ship git — install it first
if the clone below fails with "command not found":
apt-get update && apt-get install -y gitgit clone https://github.com/JohnnyVBut/cascade.git
cd cascadeThe Docker image is built by GitHub Actions on every merge to master and published
to GitHub Container Registry. No local build required:
docker compose -f docker-compose.yml pullLocal development only: to use a locally-built image, run
./build.shand add-f docker-compose.override.ymlto your compose commands.
Edit docker-compose.yml. The key variables:
environment:
- PASSWORD_HASH= # leave empty for open mode — first user created via UI
- WG_HOST=1.2.3.4 # public IP or domain (can also be set in Settings UI)
- PORT=8888 # Web UI port (listens on localhost only)
- BIND_ADDR=127.0.0.1 # bind to localhost — Caddy proxies from outsideOpen mode: if
PASSWORD_HASHis empty, the web UI shows a "Create First User" form on first visit. Create your admin account there. After creation, the form disappears and login is required.
Optional — pre-set password hash (non-interactive / CI):
docker run --rm -it ghcr.io/johnnybut/cascade:latest /app/cascade hash
# Enter password when prompted — copy the $2a$... hash into PASSWORD_HASH=docker compose -f docker-compose.yml up -dVerify it is healthy and listening on localhost:
docker ps
curl http://127.0.0.1:8888/api/health
# expected: {"host":"...","status":"ok","version":"3.0.0-alpha"}Cascade must be running (Step 8) before this step. acme.sh uses standalone mode — it temporarily binds port 80 to complete the ACME HTTP-01 challenge. Port 80 must be free (Caddy is not started yet at this point).
Install acme.sh:
curl https://get.acme.sh | sh -s email=YOUR@EMAIL.COM
source ~/.bashrcLet's Encrypt supports TLS certificates for bare IP addresses, but only via the
shortlived profile (6-day validity). Standard 90-day certificates for IPs are not
available from Let's Encrypt. Without --certificate-profile shortlived you will get:
Error creating new order :: Default profile does not permit IP address identifiers.
~/.acme.sh/acme.sh --issue \
--server letsencrypt \
-d YOUR.SERVER.IP \
--standalone \
--certificate-profile shortlivedacme.sh installs a cron job that renews automatically every 3 days — no manual action needed.
Before going live, use the LE staging environment to avoid hitting rate limits. Staging issues untrusted certificates — browsers will show a warning, but the full ACME flow is validated.
~/.acme.sh/acme.sh --issue \
--server letsencrypt_test \
-d YOUR.SERVER.IP \
--standalone \
--certificate-profile shortlived
--certificate-profile shortlivedis required for bare IPs on both staging and production. Without it the order is rejected regardless of which CA is used.
Via setup.sh:
bash deploy/setup.sh --staging # interactive
bash deploy/setup.sh --yes --staging # non-interactiveWhen you are ready for production, set ACME_STAGING=0 in deploy/.env and re-run setup.sh —
it will detect the staging cert, delete it, and issue a trusted production certificate.
If you have a domain pointing to the server, use a standard 90-day certificate instead:
~/.acme.sh/acme.sh --issue \
--server letsencrypt \
-d yourdomain.example.com \
--standaloneNo --certificate-profile flag needed. Auto-renewal every 60 days.
Install the certificate to a persistent location:
mkdir -p /etc/ssl/cascade
~/.acme.sh/acme.sh --install-cert -d YOUR.SERVER.IP \
--key-file /etc/ssl/cascade/server.key \
--fullchain-file /etc/ssl/cascade/server.crt \
--reloadcmd "docker exec cascade-caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null || true"Caddy sits in front of Cascade: serves the decoy site on HTTPS, and only routes requests under a secret path to the admin UI.
cd ~/cascade/deploy/caddy
cp .env.example .envEdit .env:
# Secret path prefix for the admin UI — choose something random, no slashes
ADMIN_PATH=your_random_secret_here
# Cascade port (must match PORT in docker-compose.yml)
CASCADE_PORT=8888Start Caddy:
docker compose up -d --build
docker compose logs -fExpected output — no errors, then:
{"level":"info","msg":"serving initial configuration"}
# Decoy site (no admin path)
curl -k https://YOUR.SERVER.IP
# → StreamVault HTML
# Admin UI (with secret path — note the trailing slash)
curl -k https://YOUR.SERVER.IP/YOUR_ADMIN_PATH/api/health
# → {"status":"ok",...}Open in browser: https://YOUR.SERVER.IP/YOUR_ADMIN_PATH/
| Port | Protocol | Purpose |
|---|---|---|
| 443 | TCP + UDP (HTTP/3) | HTTPS — Caddy (public) |
| 80 | TCP | ACME renewal only (not permanently open) |
| 8888 | TCP | Cascade UI — bound to 127.0.0.1, not public |
| 51830 | UDP | WireGuard interface wg10 (first tunnel) |
| 51831 | UDP | WireGuard interface wg11, etc. |
WireGuard UDP ports must be open in the host firewall:
ufw allow 51830:51840/udpAll Cascade state is stored in ~/cascade/data/:
data/
wireguard.db ← SQLite: interfaces, peers, routes, NAT, firewall rules, etc.
*.save ← ipset snapshots (auto-restored on startup)
/etc/amnezia/amneziawg/wg10.conf ← generated WireGuard configs (inside container)
The data directory is mounted into the container via docker-compose.yml.
cd ~/cascade
git pull origin master
docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml down
docker compose -f docker-compose.yml up -dThe image is pre-built by CI — docker compose pull fetches the latest version
from GHCR without compiling anything on the server.
Caddy does not need to be restarted for Cascade updates.
⚠️ Kernel module mode: the Docker image tracks AmneziaWG's:latestprotocol line, but the host's kernel module does not update itself. This mainly affects installs from before the AmneziaWG 3.0 protocol jump (2026-07-30) — the module stayed on the pre-3.0 line while the image moved to 3.0.x. If the two drift apart, interfaces fail to start withUnable to modify interface: Invalid argument. Re-sync the kernel module whenever you update the container in this mode:sudo bash deploy/switch-mode.sh --kernelUserspace mode is unaffected — it has no host kernel-module dependency.
Use this procedure when you need to wipe all Cascade state and start from scratch (lost admin password, corrupted database, major configuration change, etc.).
⚠️ This deletes all interfaces, peers, routes, NAT rules, and firewall rules. Export any configs you need before proceeding.
cd ~/cascade
docker compose downCascade runs with --network host, so WireGuard interfaces survive container removal.
Bring them down manually:
# List active WireGuard interfaces:
ip -d link show | grep -E "wg[0-9]+"
# Bring each one down (repeat for wg10, wg11, etc.):
awg-quick down wg10
# or for standard WireGuard:
wg-quick down wg10If awg-quick / wg-quick are not available on the host, use ip link:
ip link delete wg10# SQLite database, ipset snapshots — all configuration:
rm -rf ~/cascade/data/
# WireGuard config files generated by Cascade:
rm -f /etc/amnezia/amneziawg/wg*.conf
rm -f /etc/wireguard/wg*.conf# Remove stale FORWARD / NAT / mangle rules from previous run:
iptables-nft -F FIREWALL_FORWARD 2>/dev/null || true
iptables-nft -X FIREWALL_FORWARD 2>/dev/null || true
iptables-nft -t mangle -F FIREWALL_MANGLE 2>/dev/null || true
iptables-nft -t mangle -X FIREWALL_MANGLE 2>/dev/null || true
iptables-nft -t nat -F POSTROUTINGTip: A server reboot achieves the same result without risk of affecting other services.
docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml down
docker compose -f docker-compose.yml up -dOn first start with an empty data/ directory, Cascade creates a fresh database.
Open the UI — you will be prompted to create the first admin account.
modprobe amneziawg
# If that fails — check DKMS build status:
dkms status
uname -r # must be 6.xdocker logs cascadeCommon causes:
PASSWORD_HASHis empty or malformed
# Confirm API is reachable through Caddy:
curl -k https://YOUR.SERVER.IP/YOUR_ADMIN_PATH/api/health
# Check Cascade logs:
docker logs cascade | tail -30If the UI loads but the Interfaces page is empty — make sure you are accessing
the UI via https://YOUR.SERVER.IP/YOUR_ADMIN_PATH/ (with trailing slash).
Without the trailing slash, relative API paths resolve incorrectly.
# Re-issue the certificate:
~/.acme.sh/acme.sh --issue --server letsencrypt -d YOUR.SERVER.IP \
--standalone --certificate-profile shortlived --force
# Reinstall:
~/.acme.sh/acme.sh --install-cert -d YOUR.SERVER.IP \
--key-file /etc/ssl/cascade/server.key \
--fullchain-file /etc/ssl/cascade/server.crt \
--reloadcmd "docker exec cascade-caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null || true"
# Restart Caddy:
cd ~/cascade/deploy/caddy && docker compose restart# Check interface is up:
ip -d link show wg10
# Check NAT rule exists:
iptables-nft -t nat -L POSTROUTING -n -v | grep MASQUERADE
# Check IP forwarding:
sysctl net.ipv4.ip_forward # must be 1
# If PostUp rules are missing — Stop and Start the interface in the UIfailed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 7168 kiB, got: 416 kiB)
This is a warning, not an error. HTTP/3 works but with a smaller buffer. To silence it, apply the sysctl settings from Step 4 and restart Caddy.
On small VPS disks (e.g. 10 GB), repeated manual ./build.sh runs and normal
container operation can accumulate disk usage outside of anything Cascade
itself stores. Check where the space actually went before deleting anything:
df -h /
du -xh --max-depth=1 / 2>/dev/null | sort -rh | head -20Then drill into whichever top-level directory is largest
(du -xh --max-depth=1 /usr, /var, etc.) until you find the actual offender.
Common culprits, safest-first:
1. Docker build cache — grows with every manual docker build/./build.sh
run; a --filter "until=..." prune often leaves cache entries marked
shared: true behind. Confirm with docker system df -v (look at "Build
cache usage"), then clear it fully — this never touches running containers or
volumes, only cached intermediate build layers, so the next build is just
slower (no cache), not lossy:
docker system df -v
docker builder prune -a -f2. Dangling images — left behind when a rebuild reassigns the
cascade:latest tag to a new image, orphaning the old one:
docker image prune -f3. Docker container logs — the cascade service has no log size limit by
default (json-file driver), so long-running installs can accumulate a large
log file:
docker inspect --format='{{.LogPath}}' cascade | xargs du -h
# safe to truncate live, without touching the running container:
truncate -s 0 $(docker inspect --format='{{.LogPath}}' cascade)4. systemd journal / apt cache — not Cascade-related, but common on small VPS images:
journalctl --vacuum-size=200M
apt-get clean5. Old kernel packages — setup.sh upgrades Ubuntu 22.04 to the HWE 6.x
kernel; leftover previous kernel versions in /usr/lib/modules and
/usr/src add up. Confirm the currently running kernel first
(uname -r), then remove only the others via apt-get autoremove — never
remove the kernel that's currently booted.
6. Old pre-restore-*.tar.gz backups — created automatically before every
restore-from-backup operation, in /etc/wireguard/data/; there's currently no
automatic pruning, so old ones accumulate if you restore from backup
repeatedly:
ls -lh /etc/wireguard/data/pre-restore-*.tar.gz
rm /etc/wireguard/data/pre-restore-<old-timestamp>.tar.gzacme.sh installs a cron job automatically during installation. Verify:
crontab -l | grep acme
# expected: something like: 0 0 * * * /root/.acme.sh/acme.sh --cron --home /root/.acme.sh ...On renewal, acme.sh runs the --reloadcmd configured in Step 9,
which reloads Caddy config without downtime.