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
- Open GitHub Settings -> Developer settings -> OAuth Apps and choose New OAuth App.
- Set Homepage URL to
https://wanctl.z10.dev. - Set Authorization callback URL to
https://wanctl.z10.dev/auth/callback. The scheme, host, and port must exactly match the portal's public origin. - 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.