Control plane
Build, run, and observe the control plane in production.
The control plane is a single Go binary. You can run it under systemd, in a container, or behind any reverse proxy that terminates TLS.
One binary, many subsystems
When the binary starts, main.go wires up — in this order — the config loader, DB + migrations, plan registry, scheduler, alert manager, usage recorder, log bus, webhook dispatcher, the public API/WSS server, and the loopback internal-api (only if a token is configured). All in-process. There is no separate wisehosting-scheduler, no wisehosting-alerts, etc.
Build
go build -o wisehosting .Run
CONFIG_PATH=/etc/wisehosting/config.yaml ./wisehostingThe process exits cleanly on SIGINT / SIGTERM, which:
- Stops the webhook dispatcher (drains in-flight deliveries).
- Closes the database connection.
- Returns from
main.
TLS
You have two options:
Set api_server.tls_cert and api_server.tls_key in config. The Go process listens on api_server.host:port with TLS 1.3 minimum.
Leave tls_cert / tls_key empty and put Caddy / nginx / Traefik in front. Recommended for shared hosts.
systemd unit
The unit relies on LoadCredential= to inject every long-lived secret from the host's keystore (/etc/credstore/) into the per-process credentials directory. The Go binary reads them through cfg.loadCredential(name) so nothing sensitive ever appears in Environment=, journalctl, or the YAML on disk.
[Unit]
Description=WiseHosting control plane
After=network-online.target postgresql.service wg-quick@wg0.service
Wants=network-online.target wg-quick@wg0.service
[Service]
User=wisehosting
Group=wisehosting
Environment=CONFIG_PATH=/etc/wisehosting/config.yaml
LoadCredential=api_server_secret:/etc/credstore/wisehosting/api_server_secret
LoadCredential=database_password:/etc/credstore/wisehosting/database_password
LoadCredential=google_oauth_client_secret:/etc/credstore/wisehosting/google_oauth_client_secret
LoadCredential=github_oauth_client_secret:/etc/credstore/wisehosting/github_oauth_client_secret
LoadCredential=gitlab_oauth_client_secret:/etc/credstore/wisehosting/gitlab_oauth_client_secret
LoadCredential=bitbucket_oauth_client_secret:/etc/credstore/wisehosting/bitbucket_oauth_client_secret
LoadCredential=codeberg_oauth_client_secret:/etc/credstore/wisehosting/codeberg_oauth_client_secret
ExecStart=/usr/local/bin/wisehosting
Restart=on-failure
RestartSec=5s
LimitNOFILE=65535
[Install]
WantedBy=multi-user.targetFor each LoadCredential=name:path, the corresponding YAML key (e.g. api_server.secret, database.password, google.oauth_client_secret) can be left blank — the loader prefers the credential when both are present.
Observability
Tail the logs:
journalctl -u wisehosting -fThe control plane uses Gin's structured logger with /v1/workers/ws and /health skipped to keep heartbeats out of the noise. Look for these markers:
| Pattern | Meaning |
|---|---|
hub: worker <id> (<name>) connected via WSS | New worker registration / reconnect |
scheduler: assigned job <id> | Job dispatched to a worker |
webhook: delivery <id> | Outbound webhook attempt |
alerts: fired <kind> for app <id> | Threshold poller raised an alert |
usage: pruned <n> samples | Daily 90-day retention sweep |
Google OAuth setup
In the Google Cloud Console (APIs & Services → Credentials → OAuth 2.0 Client ID):
- Authorized JavaScript origin:
https://<your domain>. - Authorized redirect URI:
https://<your domain>/oauth/google/callback. - Copy the client ID + secret into
google.oauth_client_id/google.oauth_client_secretinconfig.yaml.
The wh_session cookie is Secure + HttpOnly, so the dashboard must be served over HTTPS in production.
Database migrations
Schema is owned by golang-migrate. On every startup the binary embeds internal/database/migrations/*.sql and applies any pending versions; a schema_migrations row records the current version and a dirty flag.
If a migration fails mid-way the row is marked dirty: true and the next start refuses to come up. Don't UPDATE schema_migrations SET dirty = false without first reconciling the partial DDL by hand or rolling back from a backup — the failure mode this design replaces is silent schema drift.
WireGuard mesh
Workers and the control plane talk over a self-hosted WireGuard mesh on 10.50.0.0/24, UDP port 51821. The control plane is 10.50.0.1, each worker takes a 10.50.0.x address. Provision with:
sudo ./scripts/wireguard-setup.sh control-planeThe script installs wireguard-tools, generates a key pair, writes /etc/wireguard/wg0.conf, opens UDP 51821 in the firewall, and enables wg-quick@wg0. The unit ordering above (After=wg-quick@wg0.service) ensures the tunnel is up before the API server starts accepting connections.
Plain HTTP rules
The worker agent refuses to start if api_server.base_url resolves to a public-internet IP over plain HTTP — without TLS the API key would cross the wire in cleartext.
https:// is always accepted. http:// is accepted only when the host parses as an IP and that IP is loopback or RFC 1918 private — in practice, http://10.50.0.1:8080 over the WireGuard tunnel. The mesh already encrypts every byte end-to-end, so terminating TLS again at the API server is redundant overhead. Any other http://... value is rejected at startup.
Backups
Two things matter:
- Postgres data — standard
pg_dump. The schema is reapplied by the binary on first connect, so a fresh DB will work; you only need backups for user data. api_server.secret— losing this means losing the AES-GCM key, which makes encrypted git tokens, env vars, TOTP secrets, and job payloads unreadable. Keep it in a secret manager.