WiseHosting
Deployment

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 ./wisehosting

The process exits cleanly on SIGINT / SIGTERM, which:

  1. Stops the webhook dispatcher (drains in-flight deliveries).
  2. Closes the database connection.
  3. 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.

/etc/systemd/system/wisehosting.service
[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.target

For 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 -f

The 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:

PatternMeaning
hub: worker <id> (<name>) connected via WSSNew 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> samplesDaily 90-day retention sweep

Google OAuth setup

In the Google Cloud Console (APIs & Services → Credentials → OAuth 2.0 Client ID):

  1. Authorized JavaScript origin: https://<your domain>.
  2. Authorized redirect URI: https://<your domain>/oauth/google/callback.
  3. Copy the client ID + secret into google.oauth_client_id / google.oauth_client_secret in config.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-plane

The 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.

On this page