Wardengate

Install

Docker Compose

Compose is the best option for a single-host production-ish install where you want the database and cache colocated with the server. The stack below runs Wardengate against a local Postgres and Valkey, all three managed by the same compose lifecycle.

For real production you will eventually want external Postgres with backups, replicas, and a clear upgrade path. Compose stays useful as a pattern for pre-prod and small internal deployments.

Layout on disk

Create a directory per environment and keep compose, env, and TLS material side by side. Everything bind-mounts into the containers.

/opt/wardengate/
  docker-compose.yml
  .env
  tls/
    fullchain.pem
    privkey.pem
  data/          # wardengate durable state
  recordings/    # recording scratch buffer
  pgdata/        # postgres data
  valkey/        # valkey AOF if persistence is enabled

docker-compose.yml

A working stack with Postgres 16, Valkey 7, and the Wardengate server. All three services join a private bridge network; only the Wardengate container exposes ports to the host.

name: wardengate

services:
  postgres:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: wardengate
      POSTGRES_USER: wardengate
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ./pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U wardengate -d wardengate"]
      interval: 10s
      timeout: 5s
      retries: 10

  valkey:
    image: valkey/valkey:7.2-alpine
    restart: unless-stopped
    command: ["valkey-server", "--save", "", "--appendonly", "no"]
    volumes:
      - ./valkey:/data

  wardengate:
    image: wardengate/server:2.4
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      valkey:
        condition: service_started
    environment:
      WG_DB_URL: postgres://wardengate:${POSTGRES_PASSWORD}@postgres:5432/wardengate?sslmode=disable
      WG_REDIS_URL: redis://valkey:6379/0
      WG_SECRET_KEY: ${WG_SECRET_KEY}
      WG_PUBLIC_URL: ${WG_PUBLIC_URL}
      WG_ADMIN_EMAIL: ${WG_ADMIN_EMAIL}
      WG_TLS_CERT_FILE: /etc/wardengate/tls/fullchain.pem
      WG_TLS_KEY_FILE:  /etc/wardengate/tls/privkey.pem
    ports:
      - "8443:8443"
      - "2222:2222"
      - "3389:3389"
      - "4400:4400"
      - "7443:7443"
    volumes:
      - ./data:/var/lib/wardengate/data
      - ./recordings:/var/lib/wardengate/recordings
      - ./tls:/etc/wardengate/tls:ro
    healthcheck:
      test: ["CMD", "wgctl", "system", "probe", "--local"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 30s

The Postgres sslmode=disable above is fine on a compose-private network; flip it to require and issue a server cert if you move the database to a different host.

.env file

Keep every secret in an .env next to the compose file. Restrict it to the operator who runs compose; the file includes the vault sealing key, which is as sensitive as the database password.

# /opt/wardengate/.env
POSTGRES_PASSWORD=replace-me-with-a-long-random-string
WG_SECRET_KEY=$(openssl rand -base64 32)
WG_PUBLIC_URL=https://wg.example.com:8443
WG_ADMIN_EMAIL=ops@example.com
sudo chown root:docker /opt/wardengate/.env
sudo chmod 640 /opt/wardengate/.env

TLS termination

Wardengate terminates TLS itself — the certificate and key you drop in ./tls/ are used by the control plane directly. Mounting the directory read-only means you can swap certificates with a simple file write and restart the service.

# refresh certificate without downtime
sudo cp newcert.pem /opt/wardengate/tls/fullchain.pem
sudo cp newkey.pem  /opt/wardengate/tls/privkey.pem
docker compose -f /opt/wardengate/docker-compose.yml kill -s HUP wardengate

A SIGHUP tells the server to reload TLS material without dropping active sessions. If you front the stack with an external load balancer that terminates TLS, set WG_TRUST_PROXY_HEADERS=true and configure PROXY protocol v2 on the LB.

First boot

cd /opt/wardengate
docker compose pull
docker compose up -d
docker compose logs -f wardengate

The first run prints a bootstrap URL in the logs — the same link that gets emailed to WG_ADMIN_EMAIL — that you click to set an initial password and generate an API token.

Logs

All services log to stdout in JSON by default. Ship the stream wherever you already send container logs: Loki, Splunk, Datadog, Elastic, CloudWatch. For local inspection:

docker compose logs -f wardengate
docker compose logs --since 15m postgres
docker compose logs --tail 200 valkey

Audit events are a separate stream from the operational log; point WG_AUDIT_SINK at a webhook or syslog target to forward them to a SIEM.

Lifecycle commands

CommandEffect
docker compose up -dStart or reconcile the stack
docker compose psShow container state and health
docker compose pull && docker compose up -dUpgrade to the pinned image tag
docker compose restart wardengateRecycle the server without touching dependencies
docker compose downStop and remove containers (bind-mounted data is preserved)

Upgrading

Bump the image tag in docker-compose.yml, then recreate the Wardengate container. Postgres and Valkey are untouched unless you also bumped their tags.

sed -i 's|wardengate/server:2.4|wardengate/server:2.5|' docker-compose.yml
docker compose pull wardengate
docker compose up -d wardengate

Troubleshooting common errors

WG_SECRET_KEY is not 32 bytes

The key must be exactly 32 bytes of entropy, base64-encoded. Regenerate with openssl rand -base64 32. Do not trim trailing = padding.

Postgres container keeps restarting

Usually caused by a pgdata/ directory owned by root from a previous run. docker compose down, fix ownership (chown -R 999:999 pgdata), and bring the stack back up.

address already in use on 3389

Another service is bound to the host port. On a shared admin host the usual culprit is xrdp. Either disable that or remap the port in the compose file to something like 33389:3389.

x509: certificate signed by unknown authority

The PEM in tls/fullchain.pem is missing intermediate certificates. Concatenate them in order (leaf first, then intermediates) and reload with SIGHUP.

Container is healthy but UI 502s from nginx

If you front compose with nginx, the upstream must speak HTTPS and trust the Wardengate cert. Setting proxy_ssl_verify off for a self-signed dev cert is fine; production should use a real cert on the Wardengate side and plain proxy_pass https://....

Related