Recipes
Each recipe is a single working snippet. The YAML / JSON blocks describe the object you’d create — paste them in the corresponding UI page, or send as the request body to POST /api/v1/<kind> (or gwctl <kind> create -).
The CLI examples shown here use the JSON-CRUD pattern: gwctl <kind> create '<json-body>'. See the gwctl reference for the canonical interface.
Routing
Section titled “Routing”A routing policy has a source (one source_type + source_value pair) and an action, expressed as a hops array. Valid source_type values: all, interface, vlan, subnet, device, device_group, destination, domain. Hop types: vpn, interface, proxy, mesh_peer. First match wins; lower priority number is checked first (conventional Linux ordering — same as ip rule). A specific policy should get a lower number than the catch-all.
Send one device through one tunnel
Section titled “Send one device through one tunnel”curl -X POST https://gateway.lan:8080/api/v1/routing/policies \ -H "Authorization: Bearer $API_KEY" \ -d '{ "name": "alice-via-eu", "priority": 100, "enabled": true, "source_type": "device", "source_value": "laptop-alice", "hops": [{"type":"vpn","target":"wg-eu-01"}], "fail_policy": "fail-closed" }'Send a whole group through a tunnel pool
Section titled “Send a whole group through a tunnel pool”Multiple vpn hops + splitting: true turns the hop list into a round-robin pool. The privacy level lives on the policy’s profile (set separately via /api/v1/profiles):
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \ -H "Authorization: Bearer $API_KEY" \ -d '{ "name": "family-pool", "priority": 90, "enabled": true, "source_type": "device_group", "source_value": "family", "hops": [ {"type":"vpn","target":"wg-eu-01"}, {"type":"vpn","target":"wg-eu-02"}, {"type":"vpn","target":"wg-asia-01"} ], "splitting": true, "fail_policy": "fail-closed" }'Send a set of domains through a bridge
Section titled “Send a set of domains through a bridge”You don’t create routing policies for bridges by hand. Register the bridge with the relevant domains list (see Bridges for the create body); the bridge subsystem auto-generates one system-managed routing policy per domain at priority 200, with hops: [{type:"proxy", target:"socks5://127.0.0.1:<bridge-local-port>"}]. The list refreshes on every reconcile tick.
To add a domain after the fact, edit the bridge’s domains field and the policies are re-emitted automatically.
If you need to drive a SOCKS5 destination that isn’t a bridge — a commercial proxy, a custom relay — use a proxy hop directly, with a URL target:
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \ -H "Authorization: Bearer $API_KEY" \ -d '{ "name": "via-commercial-proxy", "priority": 95, "enabled": true, "source_type": "domain", "source_value": "*.example.com", "hops": [{"type":"proxy","target":"socks5://198.51.100.20:1080"}], "fail_policy": "fail-closed" }'proxy hop targets must start with socks5://, socks5h://, or http:// — anything else is rejected at the API boundary.
Block one app entirely for one group
Section titled “Block one app entirely for one group”Blocks are expressed with a drop hop (not an empty hops array — that’s rejected with HTTP 400 “policy must have at least one hop”):
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \ -H "Authorization: Bearer $API_KEY" \ -d '{ "name": "kids-no-tiktok", "priority": 110, "enabled": true, "source_type": "domain", "source_value": "*.tiktok.com", "hops": [{"type":"drop"}], "fail_policy": "fail-closed" }'For multi-pattern blocks (TikTok + tiktokcdn), repeat the policy with a different source_value and the same priority bucket.
Bypass the VPN for a single SaaS that allowlists by IP
Section titled “Bypass the VPN for a single SaaS that allowlists by IP”direct egress is an interface hop pointing at the WAN:
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \ -H "Authorization: Bearer $API_KEY" \ -d '{ "name": "bank-direct", "priority": 50, "enabled": true, "source_type": "domain", "source_value": "*.bank.example", "hops": [{"type":"interface","target":"eth0"}], "fail_policy": "fail-open" }'Lower priority number than your default pool so it’s checked first and wins on match.
After any of these, push to the dataplane:
curl -X POST https://gateway.lan:8080/api/v1/routing/policies/apply \ -H "Authorization: Bearer $API_KEY"Blocks and source-scoped rules live in /api/v1/dns/filter-rules (action ∈ {block,allow,rewrite}, match_type ∈ {exact,wildcard,regex,contains,suffix}, source_type ∈ {all,ip,subnet,interface,device,device_group}). Domain-to-IP rewrites have their own simpler endpoint /api/v1/dns/rewrites.
NXDOMAIN one annoying domain
Section titled “NXDOMAIN one annoying domain”curl -X POST https://gateway.lan:8080/api/v1/dns/filter-rules \ -H "Authorization: Bearer $API_KEY" -d '{ "name":"block-ads","priority":100,"enabled":true, "action":"block","match_type":"exact","pattern":"ads.example.com", "source_type":"all","source_value":"*" }'Wildcard NXDOMAIN only for kids
Section titled “Wildcard NXDOMAIN only for kids”curl -X POST https://gateway.lan:8080/api/v1/dns/filter-rules \ -H "Authorization: Bearer $API_KEY" -d '{ "name":"kids-adult-content","priority":110,"enabled":true, "action":"block","match_type":"wildcard","pattern":"*.adult-content.example", "source_type":"device_group","source_value":"kids" }'Force a domain to a LAN IP
Section titled “Force a domain to a LAN IP”curl -X POST https://gateway.lan:8080/api/v1/dns/rewrites \ -H "Authorization: Bearer $API_KEY" -d '{ "domain": "homeassistant.lan", "answer": "10.0.0.6" }'answer may be an IPv4/IPv6 literal (A/AAAA rewrite) or a domain name (CNAME-style). Anything else is rejected at the API boundary.
One-toggle telemetry block
Section titled “One-toggle telemetry block”Enable the windows, macos and ios presets under DNS → Presets — they’re curated blocklists that ship with the daemon.
Tunnels & bridges
Section titled “Tunnels & bridges”Add a WireGuard tunnel from a provider config
Section titled “Add a WireGuard tunnel from a provider config”The simplest path is the Tunnels page in the UI — paste a .conf file or fill in the fields.
Via API:
curl -X POST https://gateway.lan:8080/api/v1/tunnels \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d @/tmp/provider-wg.jsongwctl tunnels up wg-provider-euCycle a stuck tunnel
Section titled “Cycle a stuck tunnel”gwctl tunnels down wg-eu-01 && gwctl tunnels up wg-eu-01Register a bridge
Section titled “Register a bridge”The Bridges page in the UI, or:
curl -X POST https://gateway.lan:8080/api/v1/bridges \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "bridge-eu", "address": "decoy.example.invalid:443", "psk": "REDACTED-PSK-FROM-BRIDGE-NODE", "transport": "quic", "decoy_domain": "decoy.example.invalid", "domains": ["youtube.com","*.youtube.com"], "priority": 10, "enabled": true, "fail_policy": "fail-closed" }'Privacy
Section titled “Privacy”Apply the paranoid class to a single device
Section titled “Apply the paranoid class to a single device”Edit the device on the Devices page (one-field click), or via API. PUT /api/v1/devices/{id} replaces the row, so fetch the current device first, change the one field, and send the full body back. The {id} is the UUID assigned at create time, not the hostname:
# Look up the UUID by hostnameDEV_ID=$(curl -s https://gateway.lan:8080/api/v1/devices \ -H "Authorization: Bearer $API_KEY" \ | jq -r '.[] | select(.hostname=="laptop-alice") | .id')
# Round-trip the row with one field changedcurl -s https://gateway.lan:8080/api/v1/devices/$DEV_ID \ -H "Authorization: Bearer $API_KEY" \ | jq '.privacy_class = "paranoid"' \ | curl -X PUT https://gateway.lan:8080/api/v1/devices/$DEV_ID \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ --data @-There is no PATCH for devices; the partial-update story is “GET, edit JSON, PUT”.
Define a custom class
Section titled “Define a custom class”curl -X POST https://gateway.lan:8080/api/v1/privacy-classes \ -H "Authorization: Bearer $API_KEY" -d '{ "name": "travel", "force_dns": true, "ipv6_policy": "block", "quic_policy": "block", "block_webrtc": true, "allow_direct_fallback": false }'Turn on system-wide DoH / DoT block
Section titled “Turn on system-wide DoH / DoT block”DoH has its own toggle. DoT does not — TCP/853 is dropped automatically when the DNS mode is set to paranoid:
# system-wide DoH block (by SNI against the bundled provider list)curl -X POST https://gateway.lan:8080/api/v1/privacy/block-doh \ -H "Authorization: Bearer $API_KEY" -d '{"enabled": true}'
# DoT (and the rest of paranoid-mode protections)curl -X PUT https://gateway.lan:8080/api/v1/privacy/dns-mode \ -H "Authorization: Bearer $API_KEY" -d '{"mode": "paranoid"}'In the UI: Settings → Privacy → block_doh and DNS mode → paranoid.
Temporarily bypass the VPN for troubleshooting
Section titled “Temporarily bypass the VPN for troubleshooting”Add an exception via the UI Exceptions page, or with a JSON CRUD call:
gwctl exceptions create '{ "type": "temporary-direct", "device_id": "laptop-alice", "allow_direct": true, "duration_min": 30, "reason": "Zoom troubleshooting"}'type ∈ {emergency-bypass, diagnostic-bypass, service-allowlist, temporary-direct, quarantine, captive-portal}. duration_min is minutes (max 24 h, defaults to 60). Auto-expires; nothing to remember to undo.
Dual-WAN
Section titled “Dual-WAN”Treat each WAN as a “tunnel” of type interface
Section titled “Treat each WAN as a “tunnel” of type interface”tunnels: - { id: wan-fibre, type: interface, interface: eth0, gateway: "203.0.113.1", weight: 70 } - { id: wan-lte, type: interface, interface: eth1, gateway: "198.51.100.1", weight: 30 }Pool both with session affinity
Section titled “Pool both with session affinity”profiles: - name: dual-wan action: pool pool: [wan-fibre, wan-lte] privacy_level: 1 # per-session round-robin fail_policy: fail-open # this is balancing, not privacy dns: gateway
policies: - { name: lan, match: { source_interface: eth2 }, profile: dual-wan, priority: 100 }Pin a flaky SaaS to one link
Section titled “Pin a flaky SaaS to one link”policies: - { name: bank-fibre, match: { dest_domain: "*.bank.example" }, profile: { action: tunnel, tunnel: wan-fibre, fail_policy: fail-open }, priority: 110 }Alerts
Section titled “Alerts”Page on a tunnel down
Section titled “Page on a tunnel down”The tunnel-down built-in rule is enabled by default. Wire the webhook destination through the dedicated endpoint:
curl -X PUT https://gateway.lan:8080/api/v1/alerts/webhook \ -H "Authorization: Bearer $API_KEY" \ -d '{ "webhook_url": "https://hooks.example.invalid/oncall" }'Notify Telegram on privacy leaks
Section titled “Notify Telegram on privacy leaks”leak-detected fires on any failing leak-monitor test. Same endpoint handles all three alert-channel keys; webhook + Telegram receive every fire:
curl -X PUT https://gateway.lan:8080/api/v1/alerts/webhook \ -H "Authorization: Bearer $API_KEY" \ -d '{ "telegram_bot_token": "REDACTED", "telegram_chat_id": "@my-bot" }'After saving, fire a synthetic event through the channel to prove it works: POST /api/v1/alerts/webhook/test.
Disable a noisy rule
Section titled “Disable a noisy rule”curl -X PUT https://gateway.lan:8080/api/v1/alerts/rules/new-device \ -H "Authorization: Bearer $API_KEY" \ -d '{"enabled": false}'Backup
Section titled “Backup”Take a snapshot
Section titled “Take a snapshot”gwctl backup export writes the plaintext sanitized JSON (no encryption, secrets stripped). For the AES-256-GCM-encrypted variant with secrets, use the REST API:
# Sanitized plaintext (no passphrase)gwctl backup export > /var/backups/gateway-$(date +%F).json
# Encrypted with secrets (AES-256-GCM + PBKDF2-HMAC-SHA256, 100k iters)curl -X POST https://gateway.lan:8080/api/v1/backup/export-encrypted \ -H "Authorization: Bearer $API_KEY" \ -d '{"passphrase":"REDACTED"}' \ --output /var/backups/gateway-$(date +%F).encThe daemon never logs or stores the passphrase. Lose it, lose the encrypted backup.
Schedule periodic SCP push
Section titled “Schedule periodic SCP push”The scheduler accepts three fields — enabled, interval (hours), and a single SCP target string. Rotation and timing are interval-based, not cron-shaped:
curl -X PUT https://gateway.lan:8080/api/v1/backup/config \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "enabled": true, "interval_hours": 24, "remote_target": "gateway-backup@backup.example.invalid:/srv/backups/gw01/" }'interval_hours is clamped to 1–720 (one hour to one month). remote_target looks like user@host:/path and is passed to scp — the daemon refuses shell metacharacters and --prefixed targets to block CVE-2020-15778-class injection.
The encrypted-export passphrase is not part of this config. The scheduler produces sanitized plaintext exports automatically. For encrypted backups, supply the passphrase per-call in the POST /api/v1/backup/export-encrypted body. Nothing persists it.
Restore on a fresh box
Section titled “Restore on a fresh box”gwctl backup import <file> takes a plaintext JSON file as a positional argument (not stdin). For an encrypted file, decrypt externally first — there’s no encrypted-import endpoint today:
gwctl backup import /var/backups/gateway-2026-06-26.jsonFederation
Section titled “Federation”Register a peer
Section titled “Register a peer”The simplest path is the Federation → Peers UI page (which walks key exchange + approval). Via API:
# Register the peer on this nodecurl -X POST https://gw-here.lan:8080/api/v1/nodes \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"vps-london","address":"https://vps.mesh:8080"}'
# Exchange signing keys + approve (see /features/federation/ for the full flow)Choose which kinds flow per peer
Section titled “Choose which kinds flow per peer”Sync gating is per-peer, not “per kind globally”. Each peer relationship has a direction (sync_policy) plus two allowlists (sync_resources out, allowed_receive_resources in). The kind catalog is bridges, routing, dns, devices, profiles, tunnels, segments, settings, nodes.
# On the office Pi — push routing+dns to the VPS, don't accept anything backcurl -X PUT https://gateway.lan:8080/api/v1/nodes/<vps-id> \ -H "Authorization: Bearer $API_KEY" \ -d '{ "sync_policy": "push", "sync_resources": "routing,dns", "allowed_receive_resources": "" }'
# On the VPS — pull from the office Pi, accept only routing+dns (no tunnels)curl -X PUT https://gateway.lan:8080/api/v1/nodes/<office-id> \ -H "Authorization: Bearer $API_KEY" \ -d '{ "sync_policy": "pull", "sync_resources": "", "allowed_receive_resources": "routing,dns" }'Empty allowed_receive_resources is “accept everything” — legacy behaviour. Set it explicitly even when you mean “accept all” so an upgraded peer can’t suddenly start writing kinds you didn’t anticipate.
Related
Section titled “Related”- Routing policies — match dimensions in detail.
- Use cases — end-to-end deployments combining recipes from this page.
- gwctl CLI — full command reference.