Single-Node VPS Deployment

This runbook prepares the early-access swarmhosts.com control plane on one VPS running a single-node k3s cluster.

The game servers still run on registered swarm host machines. The VPS hosts the Flask control plane, Kubernetes ingress, app state, background workers, and the operator tooling needed to build and roll out the web image.

What This Provides

  • k3s single-node Kubernetes with the bundled Traefik ingress controller.
  • Traefik ACME HTTP-01 certificate issuance for https://swarmhosts.com.
  • A VPS Kustomize overlay at deploy/kustomize/swarmhosts-single-node-vps.
  • Docker on the deploy host so scripts/release/deploy_web.sh can build and push the web image.
  • A helper script for the registry pull/push secret expected by the deployment and release script.

Initial Deployment Order

  1. Provision the VPS with an Ubuntu or Debian image and SSH access.
  2. Copy the Ansible examples and fill in the real host and operator values:
cp infra/ansible/inventories/single-node-vps.example.ini \
  infra/ansible/inventories/single-node-vps.ini
cp infra/ansible/group_vars/swarmhosts_vps.example.yml \
  infra/ansible/group_vars/swarmhosts_vps.yml

The private copies are ignored by git.

  1. Update infra/ansible/inventories/single-node-vps.ini with the VPS IP and SSH user.
  2. Update infra/ansible/group_vars/swarmhosts_vps.yml with:
  3. swarmhosts_deploy_user
  4. swarmhosts_letsencrypt_email
  5. swarmhosts_repo_url, if the playbook should clone the repo
  6. an explicit swarmhosts_k3s_version, once the first server is proven
  7. Bootstrap the node:
ansible-playbook \
  -i infra/ansible/inventories/single-node-vps.ini \
  infra/ansible/playbooks/single-node-vps.yml
  1. Clone or update the repo on the VPS at swarmhosts_repo_path.
  2. Create a private .env on the VPS deploy checkout. At minimum, set:
  3. APP_SECRET_KEY
  4. FILE_GATEWAY_TOKEN_SECRET
  5. RELAY_ROUTE_LEASE_SECRET
  6. SWARM_RELAY_REGISTRATION_TOKEN, when platform relay nodes will register
  7. LETSENCRYPT_EMAIL
  8. BOOTSTRAP_ADMIN_EMAILS
  9. MAIL_* settings when email verification/password reset should send mail
  10. DNS_* settings only when swarm host DNS automation is ready
  11. DOCKER_HUB_* settings for the private web app image repository and, if enabled, managed game version sync
  12. Create the registry secret in the k3s cluster:
REGISTRY_URL=docker.io \
REGISTRY_USERNAME=<docker-hub-user-or-namespace> \
REGISTRY_PASSWORD=<docker-hub-pat> \
KUBECONFIG=/etc/rancher/k3s/k3s.yaml \
KUBECTL=/usr/local/bin/kubectl \
scripts/release/create_web_registry_secret.sh
  1. Point DNS at the VPS:
  2. swarmhosts.com A <vps-ipv4>
  3. swarmhosts.com AAAA <vps-ipv6>, if IPv6 is configured and firewalled
  4. leave image.swarmhosts.com pointed at the onsite registry unless that registry is deliberately migrated; the production web app no longer depends on it for normal pulls
  5. Deploy the app:
KUSTOMIZE_DIR=deploy/kustomize/swarmhosts-single-node-vps \
KUBECONFIG=/etc/rancher/k3s/k3s.yaml \
KUBECTL=/usr/local/bin/kubectl \
./scripts/release/deploy_web.sh

From another workstation, use the location-aware wrapper after the local commit has been pushed:

./scripts/release/deploy_web_anywhere.sh

The default wrapper target is ubuntu@swarmhosts-vps-1 and /home/ubuntu/repos/ai-agent-swarm-hosts. It streams the pushed commit as an archive, so the VPS checkout does not need GitHub credentials.

  1. Verify:
/usr/local/bin/kubectl rollout status deployment/swarmhosts-webserver \
  -n default --timeout=180s
curl -fsS https://swarmhosts.com/healthz
  1. After the control plane is stable, add self-hosted website analytics with Umami. Keep this as a second rollout so app launch, DNS, TLS, and app persistence can be verified before adding another database-backed service. Point analytics.swarmhosts.com at the VPS, then create the Umami secret and apply the analytics manifests:
KUBECONFIG=/etc/rancher/k3s/k3s.yaml \
KUBECTL=/usr/local/bin/kubectl \
scripts/release/create_umami_secret.sh

/usr/local/bin/kubectl apply -k deploy/kustomize/umami
/usr/local/bin/kubectl rollout status statefulset/swarmhosts-umami-postgres \
  -n default --timeout=180s
/usr/local/bin/kubectl rollout status deployment/swarmhosts-umami \
  -n default --timeout=180s

Log in to Umami, create the swarmhosts.com website, copy its website ID into the deploy-host .env, and redeploy the web app:

ANALYTICS_PROVIDER=umami
ANALYTICS_SCRIPT_URL=https://analytics.swarmhosts.com/script.js
ANALYTICS_WEBSITE_ID=<umami-site-id>
ANALYTICS_DOMAINS=swarmhosts.com
ANALYTICS_TRACK_AUTHENTICATED=1

Deployment Considerations

DNS

The app ingress only routes swarmhosts.com today. Add www.swarmhosts.com later if a public redirect is desired. Do not point *.swarmhosts.com at the VPS unless that wildcard is intentionally reserved for a control-plane-owned feature; swarm host gateway DNS records should continue to resolve to the swarm host addresses they represent.

Traefik needs port 80 reachable for ACME HTTP-01 challenges, even though normal traffic redirects to HTTPS.

TLS

The single-node overlay adds a Traefik certResolver named letsencrypt to the existing IngressRoute. The Ansible playbook configures that resolver through k3s' HelmChartConfig.

Certificate issuance will not complete until public DNS points at the VPS and ports 80/443 are open.

Secrets

Keep runtime secrets out of git. The release script syncs the optional swarmhosts-web-secret from the private .env on the deploy host and preserves existing generated secrets when they are already present.

Important stable secrets:

  • APP_SECRET_KEY signs Flask sessions and CSRF state.
  • FILE_GATEWAY_TOKEN_SECRET signs file-gateway sessions used by swarm hosts.
  • RELAY_ROUTE_LEASE_SECRET signs relay route leases.
  • SWARM_RELAY_REGISTRATION_TOKEN authorizes system-owned relay nodes to register with the control plane.
  • BOOTSTRAP_ADMIN_EMAILS should be explicit before early access opens.

Registry

The deployment references swarmhost/swarmhosts-app. The harbor-credentials secret name is retained for compatibility, but its registry value should be docker.io and its credentials should be Docker Hub credentials that can push and pull the private app repository. The helper script creates the combined image pull and deploy-script login secret shape.

Migrating the registry itself to the VPS is a separate infrastructure change.

Persistence

The control plane uses the in-cluster swarmhosts-postgres PostgreSQL StatefulSet for relational state. The app still mounts the swarmhosts-web-data PVC at /data for uploads, backup archives, file bundles, logs, and retained SQLite rollback/reference data at /data/swarm.db.

For early access, set up a VPS-level snapshot or file backup before inviting users. A single-node local-path PVC is simple, but it is tied to that node and is not highly available.

Public Probes

Public reachability checks run from the swarmhosts-public-probe-worker Deployment in deploy/kustomize/swarmhosts. The worker uses the control-plane image with PUBLIC_PROBE_RUNNER=local, so checks originate from the public cluster network without SSHing from the Flask web process.

Keep one worker replica for early access. If the control plane moves to a multi-node or HA database topology, either move PostgreSQL to managed HA storage or replace direct worker DB writes with an authenticated probe-result API.

The legacy SSH probe mode still exists for lab use by setting PUBLIC_PROBE_SSH_HOST, but it is no longer the preferred VPS production shape. Avoid root passwords for any long-lived SSH probe fallback.

Website Analytics

Use Umami for the first self-hosted analytics pass. It is a better early-access fit than Plausible Community Edition or Matomo because it keeps the stack small: one web app plus PostgreSQL, no ClickHouse, no PHP/MySQL surface, and no paid cloud dependency.

Implemented shape:

  • host analytics at analytics.swarmhosts.com
  • deploy Umami in k3s under the separate deploy/kustomize/umami Kustomize entrypoint
  • use a PostgreSQL PVC sized around 10Gi to start
  • use a separate 5Gi backup PVC for daily PostgreSQL dumps
  • expose Umami through Traefik on the same websecure entrypoint and cluster certificate resolver
  • store DATABASE_URL, APP_SECRET, and PostgreSQL credentials in a Kubernetes secret
  • run a daily pg_dump backup CronJob with 14-day retention pruning
  • wire the Flask app with ANALYTICS_PROVIDER=umami, ANALYTICS_SCRIPT_URL=https://analytics.swarmhosts.com/script.js, and ANALYTICS_WEBSITE_ID=<umami-site-id>
  • include authenticated product traffic without sending user identifiers by setting ANALYTICS_TRACK_AUTHENTICATED=1

Track only coarse product events that answer launch questions:

  • start_free_clicked
  • user_login_submitted
  • user_registration_submitted
  • game_selected
  • deployment_create_submitted
  • agent_downloaded

Do not send emails, auth tokens, IP addresses, deployment IDs, swarm host IDs, server names, file paths, logs, user-entered config values, or any other freeform user data to analytics.

The browser analytics helper only forwards an explicit event allowlist and a small property allowlist (game_id, role, source, status, surface). Keys that look like secrets, email, addresses, IDs, names, paths, or logs are dropped client-side before an event reaches Umami.

Sizing

For early access, start with enough CPU and RAM headroom for image builds and app workers: at least 2 vCPU and 4 GiB RAM is a practical floor.

For a 100 GB VPS disk, keep an explicit budget:

  • OS, k3s, and system logs: 10-15 GB
  • Docker image layers and build cache: 20-30 GB
  • Swarm Hosts app PVC: 20 GB
  • Umami PostgreSQL PVC: 10-15 GB
  • backups, logs, and emergency headroom: 20 GB or more

The highest disk risk is accumulated Docker build cache and backups, not Umami itself. Add pruning or alerts before opening early access broadly.

Security

Keep the Kubernetes API private to the VPS unless there is a clear need to manage it remotely. The bootstrap playbook opens SSH, HTTP, and HTTPS only. Harden SSH separately with keys, disabled password login, and provider firewall rules once access is confirmed.

Rollback

The release script tags each web image immutably. For a bad app rollout, use Kubernetes rollout history or set the deployment image back to the previous swarmhost/swarmhosts-app:<tag>. Before risky migrations, capture a copy of /data/swarm.db, the /data tree, and a PostgreSQL dump from swarmhosts-postgres.