Self-hosting wanctl

This guide runs Postgres, the relay, and the portal on one host. The host needs two DNS names, inbound HTTPS, Docker Engine, and Docker Compose, plus roughly 1.5 GB of disk for images: the first start builds wanctl from source inside Docker, which pulls the Go and Postgres base images and compiles for a few minutes. The examples use wanctl-relay.z10.dev and wanctl.z10.dev; replace both everywhere.

1. Create a GitHub OAuth App

  1. Open GitHub Settings -> Developer settings -> OAuth Apps and choose New OAuth App.
  2. Set Homepage URL to https://wanctl.z10.dev.
  3. Set Authorization callback URL to https://wanctl.z10.dev/auth/callback. The scheme, host, and port must exactly match the portal's public origin.
  4. Create a client secret and keep the client ID and secret for the next step.

2. Configure and start the services

All compose commands in this guide run from the selfhost/ directory, where Compose picks up .env and docker-compose.yml on its own:

cd selfhost
cp .env.example .env
chmod 600 .env

Edit .env: set RELAY_PUBLIC_ORIGIN and PORTAL_PUBLIC_ORIGIN to your two public origins, set WANCTL_GITHUB_CLIENT_ID and WANCTL_GITHUB_CLIENT_SECRET from step 1, and generate each local secret as described in its comment. The file holds all of the deployment's secrets, hence the chmod. Then start the stack:

docker compose up -d

The first start compiles wanctl inside Docker before anything comes up. If the host cannot reach proxy.golang.org, the build fails on module download timeouts — set GOPROXY in .env to a mirror you can reach (for example GOPROXY=https://goproxy.cn,direct) and run docker compose build again.

The relay waits for Postgres, runs its embedded migrations, and then reports healthy. The portal starts only after that. Inspect startup with:

docker compose ps
docker compose logs relay portal

Two log lines on a fresh deployment read like failures but are expected: release distribution disabled: read manifest: ... (no signed release directory is mounted) and lark approval disabled: ... (the optional Lark integration is not configured). Neither affects anything in this guide.

Postgres and the portal identity live in named volumes. docker compose down keeps them; docker compose down -v permanently removes them.

3. Put HTTPS in front

Both application ports bind only to loopback. A minimal Caddyfile is:

wanctl-relay.z10.dev { reverse_proxy 127.0.0.1:8080 }
wanctl.z10.dev { reverse_proxy 127.0.0.1:8081 }

Equivalent nginx server blocks are:

server {
    listen 443 ssl;
    server_name wanctl-relay.z10.dev;
    # Configure ssl_certificate and ssl_certificate_key for this name.
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;
    }
}

server {
    listen 443 ssl;
    server_name wanctl.z10.dev;
    # Configure ssl_certificate and ssl_certificate_key for this name.
    location / {
        proxy_pass http://127.0.0.1:8081;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;
    }
}

wanctl uses finite HTTP long-poll requests by default, so WebSocket upgrade support and streaming-specific timeouts are not required. Disabling nginx response buffering is sufficient; no other special proxy behavior is needed.

Before moving on, confirm each leg of the chain:

curl -s http://127.0.0.1:8080/healthz             # relay, direct: prints "ok"
curl -s https://wanctl-relay.z10.dev/healthz          # relay, through the proxy: "ok"
curl -s https://wanctl.z10.dev/healthz         # portal, through the proxy: "ok"
curl -s -o /dev/null -w '%{http_code}\n' https://wanctl.z10.dev/   # 303 (redirect to login)

The two /healthz endpoints are also the right targets for uptime monitoring; note that the applications answer GET, not HEAD.

4. Sign in and enroll devices

Open https://wanctl.z10.dev. On a new database, the first GitHub account to complete login becomes the administrator. Later accounts remain on the pending page until invited.

Create an invite code for a second user with the admin CLI already inside the relay container (nothing beyond Docker is needed on the host):

docker compose exec -e WANCTL_RELAY=http://127.0.0.1:8080 relay wanctl admin invite

Alternatively, pre-approve a specific GitHub login by appending --github LOGIN. Give the one-time code to the user; the pending page accepts it.

On a device, install the signed binary from the project release page, point it at your instance (persisted; wanctl config shows and edits it later), then enroll and start it:

curl -fsSL https://github.com/Daily-AC/wanctl/releases/latest/download/install.sh | sh
wanctl config set relay=https://wanctl-relay.z10.dev portal=https://wanctl.z10.dev
wanctl start

The last command opens the portal, asks for the one-time enrollment code shown there, stores the issued token, and starts the agent. The agent makes outbound connections only. (wanctl start with nothing configured prompts for the two URLs on a terminal. Bare wanctl prints the help and does nothing, so running it on a machine you only control from leaves that machine alone.)

Optional: enable the portal device console

The portal can issue tokens and enroll devices without a portal token. Its live device console additionally needs a privileged token in the reserved portal namespace. After the relay is running, export the admin secret and issue one:

export WANCTL_ADMIN_SECRET='<value from selfhost/.env>'
curl -fsS https://wanctl-relay.z10.dev/admin/tokens/issue \
  -H "X-Admin-Secret: $WANCTL_ADMIN_SECRET" \
  -H 'Content-Type: application/json' \
  --data '{"namespace":"portal","label":"self-hosted portal","days":0}'

Set the returned wanctl_... value as WANCTL_PORTAL_TOKEN in selfhost/.env, then apply it with:

docker compose up -d portal

Optional: enable the hosted MCP endpoint

An AI host that cannot start a local wanctl process — a browser chat, a cloud agent runner — can talk to the relay's built-in MCP server over HTTP instead. Give the relay a seed and recreate it:

openssl rand -hex 32   # paste the value into WANCTL_MCP_SEED in selfhost/.env
docker compose up -d --no-deps relay

The relay logs MCP server enabled at /mcp on startup and serves https://wanctl-relay.z10.dev/mcp. If your edge proxy has already claimed the /mcp prefix, point hosts at https://wanctl-relay.z10.dev/wanctl-mcp instead: it is an alias onto the same handler and the same sessions, so nothing else changes. Each MCP session signs in separately through the portal; Connect an AI over MCP is the guide for whoever does that. Keep the seed stable — changing it signs every session out and voids every saved rebind credential.

A hosted session keeps its device trust in memory only, so the first time it reaches a device it stops at a fingerprint it cannot confirm by itself: the session can list devices but run nothing. Accepting that your own relay is not the attacker this guards against is what the opt-in costs; leave it unset and the endpoint stays read-only:

# selfhost/.env, then: docker compose up -d --no-deps relay
WANCTL_MCP_ALLOW_UNSAFE_TRUST_SERVER=1

Optional: let web AIs sign in with OAuth

A connector that opens a new MCP session per tool call — ChatGPT's does — can never hold a session-keyed login. Such clients authenticate with an OAuth 2.1 bearer instead, and the relay turns that on by itself once it has all three of a database, a public origin and a portal:

# selfhost/.env, then: docker compose up -d --no-deps relay
WANCTL_PUBLIC_ORIGIN=https://wanctl-relay.z10.dev
WANCTL_PORTAL=https://wanctl.z10.dev

The relay logs MCP OAuth enabled and publishes /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server; the consent page a person actually sees is served by the portal at /oauth/authorize. Clients register themselves, which grants nothing on its own — access begins when a signed-in person approves the request, and each approval appears in their token list labelled with the client's name, revocable there. Nothing changes for clients that send no bearer: they keep the per-session login. Migration 010 adds the two tables this needs, so back up the database before the first start on this version, as with any other schema change.

Optional: serve signed releases to devices

With release distribution enabled, devices install with one line and later upgrade with wanctl update, both verified against the project's release signature. Download a release's assets from GitHub Releases into selfhost/dist/, then tell the relay which signing key to trust — the trust anchor is compiled in at build time, deliberately not runtime config:

gh release download v0.2.0 --repo Daily-AC/wanctl --dir dist   # or curl each asset
# Derive the raw base64 key the relay build expects from the release's PEM:
openssl pkey -pubin -in dist/release-public.pem -outform DER | tail -c 32 | base64

Set that value as WANCTL_RELEASE_PUBLIC_KEYS in .env, then rebuild and restart the relay:

docker compose build relay && docker compose up -d relay

The relay log switches from release distribution disabled to serving /dl/*, and a device on a network that cannot reach GitHub installs from the mirror:

curl -fsSL https://wanctl-relay.z10.dev/install.sh | sh
irm https://wanctl-relay.z10.dev/install.ps1 | iex

Nothing has to be exported: a relay rewrites the installers it serves to point at its own /dl, since whoever could fetch the script from this relay can reach this relay — and often cannot reach the release page the script was built for. WANCTL_RELAY and WANCTL_DIST_BASE still override it, and a copy taken from the GitHub release page is unaffected. This requires RELAY_PUBLIC_ORIGIN (the compose file passes it to the relay as WANCTL_PUBLIC_ORIGIN).

Binaries installed this way still run wanctl update against the release page baked in at build time. On a network that cannot reach it, point them at the mirror once:

wanctl config set release_base=https://wanctl-relay.z10.dev/dl

This mirror is optional: official binaries and installers default to the project's GitHub release page for both install and wanctl update, so most deployments never need to serve /dl at all.

Devices do not wait to be told. A running wanctl agent checks the same signed manifest a minute after it starts and every six hours after that, and installs a newer release itself — verifying the signature, then restarting in place while keeping its pid. It skips the check while a session or job is running, and a binary in a directory it cannot write logs that sudo wanctl update is needed rather than attempting to elevate. wanctl config set auto_update=off on a device opts it out. When the release page is unreachable but the relay's mirror is configured, the automatic check falls back to the mirror exactly as wanctl update does.

Troubleshooting

GitHub reports a callback URL error. The OAuth App callback must exactly match PORTAL_PUBLIC_ORIGIN plus /auth/callback. Check the scheme, hostname, port, and any stale example value in selfhost/.env.

The portal exits with a session-secret error. WANCTL_SESSION_SECRET is measured in bytes and must be at least 32 bytes. openssl rand -hex 32 produces a suitable 64-character value. Recreate the portal after changing it.

The relay cannot connect to DATABASE_URL. It pings Postgres before serving and exits if the connection or embedded migration fails. Compose then restarts it under restart: unless-stopped; inspect docker compose logs relay postgres, and verify the database credentials. Do not disable migrations on a fresh database.