Watch the SmallWorlds Lightning Talk.
Refer to the SmallWorlds Architecture Diagram for system topology and data flows.

The SmallWorlds dashboard — auto-discovered applications and infrastructure at a glance.
Warning
Prototype — not production-ready. SmallWorlds is still in an early prototyping state. It is intended for experimentation and evaluation only, and is not yet suitable for production environments. Expect breaking changes, incomplete hardening, and no stability or upgrade guarantees. Use at your own risk.
Important
Not Hetzner-only. smallworlds-init.sh supports two deployment targets: hetzner (a Hetzner Cloud VM provisioned by Terraform) and local (an existing Linux machine in your LAN, bootstrapped in place over SSH — no cloud account needed). Hetzner is still required for public DNS — either via Terraform on the hetzner target, or via a free Hetzner DNS zone + API token on an internet-exposed local deployment. A LAN-only local deployment needs no Hetzner account, no domain registration, and no cloud resources at all. See the "Deployment Instructions" section below and doc/local-deployment.md for details.
This document outlines the deployment process for a SmallWorlds GitOps cluster. The architecture relies on an upstream foundation repository and a private, user-controlled configuration repository.
This project is built upon several foundational open-source technologies, core infrastructure services (installed by default), and optional user applications (selectively installed during initialization):
| Name | Source URL | Role in this Project |
|---|---|---|
| Terraform | terraform.io | Infrastructure as Code tool used on the hetzner target to provision the VM and DNS; on the local target only the (optional) DNS zone is Hetzner-managed, the node itself is bootstrapped over SSH without Terraform. |
| Kubernetes | kubernetes.io | Core container orchestration system that serves as the foundation for the cluster. |
| Argo CD | argoproj.github.io/cd | GitOps continuous delivery tool that synchronizes cluster state with the configuration repository (accessible at deploy.<domain>). |
| Velero | velero.io | Cluster backup and disaster recovery solution. |
| Grafana | grafana.com | Operational dashboard for cluster monitoring and observability. |
| CloudNativePG | cloudnative-pg.io | High-availability PostgreSQL database clustering. |
| Garage | garagehq.deuxfleurs.fr | S3-compatible object storage backend. |
| Homepage | gethomepage.dev | Application dashboard automatically configured and accessible at dashboard.<domain>. |
| Keycloak | keycloak.org | Identity Provider (IdP) for Single Sign-On (SSO) and WebAuthn/Passkey management. |
| Stalwart | stalw.art | Self-hosted mail server with OIDC directory integration. |
| Traefik | traefik.io | Ingress routing and reverse proxy for handling incoming requests. |
| Cert-Manager | cert-manager.io | Automated TLS certificate provisioning and management. |
| Operator Console | operator-console/ |
The privileged administration interface (console.<domain>). Authenticates through Keycloak with Observer/Operator/Owner roles and explains every capability from configuration, delivery, runtime, access and protection evidence. Reachable only from the Private Network. See doc/tenant-operator-console.md. |
| Name | Source URL | Role in this Project |
|---|---|---|
| Collabora Online | collaboraonline.com | Powerful online office suite for collaborative document editing. Integrated into Nextcloud. |
| Excalidraw | excalidraw.com | Virtual collaborative whiteboard tool. |
| Forgejo | forgejo.org | Git repository management and software collaboration. |
| Immich | immich.app | High performance photo and video backup. |
| Jitsi Meet | jitsi.org | Secure video conferencing and communication platform. |
| Nextcloud | nextcloud.com | File synchronization and collaboration. |
| Plane | plane.so | Open-source project management tool. |
| Bulwark | github.com/bulwarkmail/webmail | JMAP webmail client connected to Stalwart. |
SmallWorlds supports two deployment targets — the installer asks which one you want as its first question:
hetzner— a Hetzner Cloud VM provisioned by Terraform, with public DNS and Let's Encrypt certificates. The internet-facing default.local— an existing Linux machine in your LAN (laptop, mini-PC, home server; 32 GB RAM recommended), bootstrapped in place over SSH without Terraform. By default LAN-only: no public DNS (you add router-DNS or/etc/hostsentries) and self-signed certificates. Optionally internet-exposed: with a registered domain, a Hetzner DNS token and router port forwards (80/tcp, 443/tcp, 10000/udp), the wizard sets up Let's Encrypt certificates and an in-cluster DDNS job that keeps the DNS records pointed at your home IP. Seedoc/local-deployment.mdfor requirements and limitations before you start.
Steps 1 and 3 below apply to the hetzner target only; steps 2 and 4 apply to both.
Important
Domain Registration is manual: The SmallWorlds setup scripts do not automatically register or reserve the domain name for you. You must manually register the domain at a registrar of your choice (e.g., Hetzner Domain service, Namecheap, Cloudflare) and point the domain's Nameservers to Hetzner's DNS servers (e.g., helium.ns.hetzner.de, oxygen.ns.hetzner.com, hydrogen.ns.hetzner.com).
Domain registration will incur costs at your registrar.
For a LAN-only local deployment none of this applies — the domain never has to be registered, since name resolution happens inside your LAN. For an internet-exposed local deployment, domain registration and the Hetzner nameservers ARE required (records are then managed by the in-cluster DDNS job instead of Terraform).
The DNS zone and DNS records are automatically managed via the Hetzner API token provided during provisioning (which is free of charge). Subdomains are routed to the provisioned server IP.
A .dev cluster additionally gets its own scoped mail domain: addresses are user@dev.<domain>, with MX/SPF/DKIM/DMARC records placed under the dev subdomain so they can never conflict with production's mail records at the zone apex (see doc/tenant-stalwart.md).
A private Git repository is required to store application state and configuration overrides.
Execute the initialization script from the root of this repository:
./admin-tools/prepare-community-repo.shThis script handles:
- Prompting for your target domain (e.g.
smallworlds.network) and environment extension in subdomain syntax (e.g..devfor a dev cluster, giving hostnames likeidentity.dev.smallworlds.network; leave empty for production). - Automatically creating a private GitHub repository using the
ghCLI (if installed), or allowing you to provide an empty Git URL manually. - Interactive selection of optional applications.
- Generation of the corresponding
kustomization.yamloverlays, injecting environment-specific patches for the chosen domain. - Initialization of the local Git repository and automatic push to your remote repository.
For the local target, skip this section — instead make sure the target machine meets the requirements in doc/local-deployment.md (systemd Linux, SSH + sudo access, firewalld disabled or configured for k3s, 100 GB+ free disk).
For the hetzner target, these steps are required:
- Create a Hetzner Cloud account and a new project.
- Generate an API Token with Read & Write permissions. Save this token.
- In the Hetzner Cloud Console, navigate to Primary IPs and click Create Primary IP. Select IPv4, choose your target location (e.g., Helsinki/hel1), and name it exactly
smallworlds-ip(orsmallworlds-ip-devif deploying a.devenvironment — Hetzner resource names always use the dash form, regardless of the DNS syntax). Leave it unassigned. Terraform will attach it during provisioning.
Warning
Before your first run against a fresh hetzner node (or a first-time internet-exposed local node), make sure your overlay repo (my-community-config, from step 2) is pinned to the latest smallworlds release tag — see "Managing Updates" below. Some infrastructure-level changes (cloud-init) take effect immediately on a brand-new node, while their corresponding ArgoCD-managed component only appears once your overlay's pinned tag includes it. If those are out of sync at first boot, the result isn't a degraded app or two — it can be a total outage (e.g. every TLS certificate stuck, no app reachable) with nothing obviously pointing at the cause. A newly created overlay may not have caught up via the weekly Renovate PR yet, so check/bump it manually first.
Execute the bootstrap script to provision the server and install Kubernetes/ArgoCD. On the hetzner target it provisions the VM and DNS via Terraform; on the local target it bootstraps your LAN machine over SSH (asking for its SSH target, e.g. root@192.168.1.50, or localhost to install on the machine you are running the script on).
git clone https://github.com/stephan271/smallworlds.git
cd smallworlds
./smallworlds-init.shWhen prompted for Git credentials, provide:
- URL: The HTTPS URL of your private configuration repository (SSH URLs are unsupported).
- Username: Your Git platform username.
- Access Token: A Personal Access Token (PAT) with read-only access to repository contents.
By default, registration is invitation-only. To enable self-registration, patch the Keycloak configuration via your kustomization.yaml:
patches:
- target:
kind: Job
name: keycloak-realm-config
namespace: keycloak
patch: |-
- op: replace
path: /spec/template/spec/containers/0/env/1/value
value: "self-registration"To deploy external applications, add standard Kubernetes manifests to your configuration repository and declare them in your kustomization.yaml. ArgoCD will synchronize the state.
The rebuild procedures below are for the hetzner target (they drive Terraform). For local deployments, the equivalent lifecycle (uninstall/reinstall preserving data, full wipe) is described in doc/local-deployment.md.
This procedure replaces the VM while retaining the persistent volume containing cluster state and data.
cd infrastructure/terraform
terraform destroy -target=hcloud_server.smallworlds_pilot_node
terraform applyThis procedure wipes all cluster data but first backs up the TLS certificates to your local machine (~/.smallworlds/cert-backups/<production|dev>/), then re-injects them into the new cluster to avoid Let's Encrypt rate limits. For the dev cluster, prefix each command with ENV_EXT=".dev".
./admin-tools/prepare-fresh-rebuild.sh
cd infrastructure/terraform
terraform destroy -target=hcloud_server.smallworlds_pilot_node
terraform apply
cd ../.. && ./admin-tools/restore-certs-from-laptop.shThe restore script waits for the new cluster's API, then applies the saved certificate secrets before cert-manager re-issues them; if no local backup exists it exits cleanly and certificates are issued fresh. You can also snapshot certificates at any time without a rebuild via ./admin-tools/backup-certs-to-laptop.sh.
Storage layout, the backup chain, restore procedures and scaling paths are all
documented in doc/storage-and-backup.md. The chain is
Garage-first: databases (CloudNativePG/barman), Velero's cluster-state dumps and
nightly PV copies all land in the in-cluster Garage S3, and a single replicator
CronJob mirrors every bucket offsite at 04:00.
Important
The offsite leg requires one-time operator setup — an S3 target (recommended:
a Backblaze B2 bucket with versioning) and the replicator-config-secret.
Until then, backups never leave the node and the nightly replicator job fails
(which the monitoring emails about). Follow
infrastructure/kubernetes/tenants/backup-replicator/README.md.
SmallWorlds runs on two repositories, and understanding their interplay is the key to safe day-2 operations:
| Repo | Role | Who changes it |
|---|---|---|
smallworlds (this repo, public) |
The upstream base: all app/infra manifests under infrastructure/kubernetes/. Released as semver tags (v1.0.0, v1.1.0, …). |
The SmallWorlds project. |
my-community-config (yours, private) |
The overlay ArgoCD actually deploys from. Each app's kustomization.yaml remote-references the base at a pinned tag (?ref=v1.0.0) plus your local patches. |
You, the operator. |
Each cluster (production, .dev) has its own private overlay repo, generated the same way by prepare-community-repo.sh with the matching environment extension.
ArgoCD only watches your private overlay. It does not track the base's moving branch. Because the overlay pins the base to an immutable tag, upstream changes never reach your cluster on their own — adopting a new base version is always a deliberate, auditable action in your repo.
Note
This separates two independent concerns. Drift reconciliation (ArgoCD selfHeal) keeps the cluster matching whatever is declared and stays on — it's safe and low-risk. Version adoption (moving to newer upstream code) is the deliberate lever described below. Don't conflate them.
Bump the pinned tag everywhere in your overlay and commit — ArgoCD (which watches this repo) then syncs the change deterministically:
# in my-community-config, e.g. v1.0.0 -> v1.1.0
grep -rl 'v1.0.0' . | xargs sed -i 's#v1.0.0#v1.1.0#g'
git commit -am "Bump upstream smallworlds base to v1.1.0" && git pushRollback is just as simple: revert that commit. Because the ref is immutable, what you tested is exactly what deploys.
Tip
prepare-community-repo.sh pins to a release tag by default (it prompts for the version). You can answer HEAD to always track the latest main, but avoid it in production: ArgoCD only re-pulls a floating HEAD non-deterministically (on cache expiry), so you lose reproducibility and can't tell what's actually running.
An in-cluster Renovate CronJob is pre-wired to reduce the toil without giving up control. Every Monday it opens one pull request in your private overlay that bumps the pinned base tag to the newest smallworlds release (config in my-community-config/renovate.json). It does not auto-merge — you review the changelog and merge when ready; the merge is the commit that triggers ArgoCD. This gives you a low-effort cadence and a human gate and a full audit trail.
Requirements for the PR automation:
- The private overlay must be listed in the Renovate CronJob's
RENOVATE_REPOSITORIES(added via an overlay patch in yourkustomization.yaml, so operator-specific config stays out of the public base). - The Git token Renovate uses (
repo-git-creds) must have pull-request / write access to the private overlay repo, not just read.
The admin-tools/ directory contains helper scripts to automate common operations and maintenance tasks:
backup-certs-to-laptop.sh: Downloads Let's Encrypt certificates from the cluster to your local machine to avoid rate limits during rebuilds.restore-certs-from-laptop.sh: Injects previously backed-up certificates into a newly built cluster before cert-manager re-issues them.prepare-fresh-rebuild.sh: Prepares a cluster for a clean reinstall by backing up certificates and wiping all persistent application data on the server.destroy-cluster.sh: Wraps the cluster teardown process. It automatically backs up your certificates and then safely executesterraform destroyto delete the cloud resources.build-golden-image.sh: Builds a pre-configured Hetzner snapshot (golden image) to speed up future VM provisioning.
prepare-community-repo.sh: Interactive script to initialize a new private configuration repository, select applications, and wire it up to the upstream base.bump-version.sh: Automatically detects the highest release tag, increments the patch version, creates a new Git tag, and pushes it to origin.update-community-version.sh: Updates allkustomization.yamlfiles in your private config repository to pin them to a specific upstream release, then commits and pushes the change.
test-pr-locally.sh: Deploys a local testing environment to validate pull requests.generate_domain_patches.py: Automatically generates Kustomize domain patches for all applications when a non-default domain or environment extension (e.g.,.dev) is used.bulk-invite.py&update_realm_json.py: Python utilities for Keycloak realm manipulation and user invitations.
Browser-based Playwright smoke tests simulate real users logging in via SSO and exercising each application. They live in e2e-tests/tests and run against a live SmallWorlds community.
./e2e-tests/run-smoke-tests.sh <domain> [keycloak-admin-password]
# e.g.
./e2e-tests/run-smoke-tests.sh smallworlds.networkIf the Keycloak admin password is omitted, the runner reads it from the cluster via kubectl. The script checks service availability, provisions two test users (sw-test-alice, sw-test-bob), and runs the suite.
Full OIDC login roundtrips require the applications to trust the TLS certificate of identity.<domain> for their server-side discovery/token calls. That holds in production (Let's Encrypt) but is structurally impossible on ephemeral staging clusters. The suite therefore runs at one of two depths:
| Mode | How to run | What it verifies |
|---|---|---|
| Shallow wiring (default) | ./e2e-tests/run-smoke-tests.sh <domain> |
Each app redirects into Keycloak's authorize endpoint — proving client config, secrets, issuer URL, in-cluster DNS, and OIDC wiring. The deeper login-roundtrip tests are skipped. |
| Full OIDC | FULL_OIDC=1 ./e2e-tests/run-smoke-tests.sh <domain> |
The complete login roundtrips run — auto-login into each app, then asserting the app's authenticated UI loads (Files listing, inbox, timeline, dashboard, etc.). Requires app-trusted certificates, i.e. production. |
In shallow mode you'll see the roundtrip tests reported as skipped (with the reason Full OIDC roundtrip needs app-trusted certificates — run with FULL_OIDC=1); this is expected, not a failure.
These environment variables override the CLI arguments:
| Variable | Effect |
|---|---|
DOMAIN |
Target domain (alternative to the first positional argument). |
KC_ADMIN_PASS |
Keycloak admin password (alternative to the second positional argument). |
FULL_OIDC=1 |
Run the full OIDC login roundtrips (see above). |
HEADED=1 |
Run in headed browser mode instead of headless. |
SLOWMO=500 |
Slow operations down by the given number of milliseconds — useful when watching a headed run. |
SKIP_PROVISION=1 |
Skip test-user provisioning (reuse existing sw-test-* users). |
KUBECONFIG |
Path to the kubeconfig used to read the admin password (default: ~/.smallworlds/kubeconfigs/<production|dev>.yaml, matching the configured env_ext). |
The HTML report is written to e2e-tests/reports/html; view it with cd e2e-tests && npx playwright show-report reports/html.
A SmallWorlds release is a Git tag plus two independently published families of signed attachments. Tagging alone does not produce a usable release — the tag only becomes installable once both publishing workflows have run against it.
| Group | Files | Produced by | Consumed by |
|---|---|---|---|
| Bootstrap assets (4) | smallworlds-bootstrap-<tag>-linux-amd64.tar.gz + .sha256 + .sig, bootstrap-assets.manifest.json |
Publish Bootstrap Assets | The launcher, which downloads and verifies them, then installs their contents onto the Cluster Node. |
| Launcher artifacts (10) | 5 native archives (linux_amd64, linux_arm64, darwin_amd64, darwin_arm64, windows_amd64), SHA256SUMS + .sig + .pub, the SPDX SBOM, THIRD-PARTY-NOTICES.txt |
Publish Bootstrap Launcher | Operators, who download one archive by hand to get the launcher in the first place. |
Note
The GitHub UI shows two extra entries — "Source code (zip)" and "Source code (tar.gz)". Those are auto-generated by GitHub and are not real attachments, so a complete release reads as 16 in the browser but 14 via gh release view --json assets.
A tag carrying only the bootstrap assets is installable by an operator who already has a launcher, but offers no way to obtain one. Releases v1.2.25 through v1.2.27 are in exactly that state; v1.2.28 is the first complete release.
Both workflows sign with the repository secret SMALLWORLDS_RELEASE_ED25519_PRIVATE_KEY_B64. See docs/releases/github-release-signing-setup.md.
Important
The signing key must match defaultReleaseSigningPublicKey, compiled into the launcher under operator-console/internal/bootstrapassets/. That compiled key is the only trust anchor a launcher has: assets signed by any other key are rejected outright, with no override. Rotating the release key is therefore a code change plus a launcher rebuild, not a secrets change.
Every release needs a reviewed lock at docs/releases/bootstrap-inputs/<tag>.json pinning the exact K3s installer and Argo CD manifest — version, HTTPS URL, and SHA-256 — that the packager may download. Copy the previous release's lock, update release, and either keep the pins (re-verify the digests against their official sources first) or bump them.
cp docs/releases/bootstrap-inputs/v1.2.28.json docs/releases/bootstrap-inputs/v1.2.29.json
# edit "release", then independently re-verify each digest:
curl -sS https://get.k3s.io | sha256sumCommit and push this before tagging. See docs/releases/bootstrap-inputs/README.md.
./admin-tools/bump-version.sh # tags main with the next patch version and pushesWarning
Both workflows build from the tagged tree, not from main. Anything committed after the tag is invisible to them, and a tag can never be packaged with tooling it does not contain. This is precisely why v1.2.25–v1.2.27 cannot be given launcher artifacts retroactively: admin-tools/package-bootstrap-launcher.sh was committed after all three were tagged. The fix is always a new tag, never a re-run.
bump-version.sh only tags — it does not create the input lock. Do step 1 first.
Run both workflows with publish=false first. Each builds and signs with a throwaway key and uploads the result as a downloadable workflow artifact, touching no release:
gh workflow run publish-bootstrap-assets.yml -f release=v1.2.29 -f publish=false
gh workflow run publish-bootstrap-launcher.yml -f release=v1.2.29 -f publish=false
gh run list --workflow=publish-bootstrap-launcher.ymlNote
In a validation run the step "Publish GitHub Release attachments" reports skipped and "Upload validation artifact" reports success. That is the designed outcome, not a failure.
gh workflow run publish-bootstrap-assets.yml -f release=v1.2.29 -f publish=true
gh workflow run publish-bootstrap-launcher.yml -f release=v1.2.29 -f publish=trueRun the assets workflow first: it creates the GitHub Release object if absent (gh release view || gh release create), whereas the launcher workflow only does gh release upload and needs the release to exist.
The launcher job takes roughly five minutes — npm ci, the client build, go test ./..., then five cross-compiles — and uploads all ten files in its final step. An empty-looking release mid-run is normal; check the run, not the release page. Avoid dispatching concurrent publish=true runs against one tag: they race on gh release upload --clobber over identical filenames.
gh api repos/stephan271/smallworlds/releases/tags/v1.2.29 -q '.assets | length' # expect 14
gh release download v1.2.29 -D /tmp/verify -p 'SHA256SUMS*' \
-p 'smallworlds-bootstrap-launcher_v1.2.29_linux_amd64.tar.gz'
cd /tmp/verify
openssl pkeyutl -verify -rawin -pubin -inkey SHA256SUMS.pub -in SHA256SUMS -sigfile SHA256SUMS.sig
sha256sum -c --ignore-missing SHA256SUMS
# the published key must equal the launcher's compiled trust anchor
openssl pkey -pubin -in SHA256SUMS.pub -outform DER | tail -c 32 | base64 -w0
grep -o 'defaultReleaseSigningPublicKey = "[^"]*"' \
operator-console/internal/bootstrapassets/*.goThe last check is the one that matters: if the published key and the compiled anchor diverge, every launcher will refuse the release.
To exercise packaging without touching GitHub — worth doing before a first release from a materially changed tree:
cd operator-console/web && npm ci && npm run generate:api && npm run check && npm run build
cd .. && go test ./...
cd .. && SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" \
admin-tools/package-bootstrap-launcher.sh --version v1.2.29 --output-directory /tmp/launcher-test
admin-tools/test-build-bootstrap-assets.sh # structural + reproducibility check for the asset archivePackaging is deterministic within a toolchain: CI pins Go 1.25.0, so archives built locally on a different Go version will not match CI's checksums. That is expected and not a reproducibility failure. A local GOEXPERIMENT (e.g. nodwarf5) can also cause spurious internal compiler errors during the cross-compiles; clear it with GOEXPERIMENT=none.
Tip
Adding a descriptor for the new release to DefaultCatalog() in operator-console/internal/bootstrapassets/manager.go is optional. A launcher that has never heard of a release falls back to fetching that release's published bootstrap-assets.manifest.json and accepting it solely on the compiled signing key's word — which is what lets an old launcher install a newer release, and what avoids the circularity of needing an archive's digest before the archive exists.
Publishing does not deploy anything. Each operator's private overlay pins an immutable tag, so the new release only reaches a cluster when someone bumps that pin — either manually or by merging the weekly Renovate PR. See "Managing Updates — the two-repo model" above.
When adding a new application (tenant) to the SmallWorlds cluster, please ensure you complete all the items on this integration checklist:
- Pin Specific Versions: Always use a specific, stable container image tag (e.g.,
v2.4.1or24.04) rather thanlatestin your Kubernetes manifests to ensure reproducible deployments. - Add E2E Tests: Write end-to-end smoke tests (using Playwright) in the
e2e-tests/testsdirectory to verify the application's core functionality and SSO integration. - Integrate with Dashboard: Add Homepage annotations (e.g.,
gethomepage.dev/enabled: "true") to the application's Ingress resource so it automatically appears in the user dashboard. - Make it Selectable: Add the application's identifier to the
OPTIONAL_APPSarray in theadmin-tools/prepare-community-repo.shscript so users can easily toggle its installation. - Update the README Table: Add the application to the "End User Applications" table in this README file, including a description and its source URL.
- Document the Implementation: Add a descriptive markdown file (or update an existing one) in the
doc/directory detailing the application's YAML manifests, configurations, and architecture. - Configure DNS Records: Add the application's generic subdomain (e.g.,
whiteboard,meet,office) to the DNS records array ininfrastructure/terraform/main.tfso Terraform provisions the A-record. - Provision Web Certificates: Ensure the Ingress resource specifies the correct
cert-manager.io/cluster-issuer: letsencrypt-prodannotation and thetlshosts block to automate Let's Encrypt SSL certificate generation.