Sophon provisions a self-contained homelab onto a Proxmox host: an Alpine NFS server, a Fedora CoreOS VM, and a set of services — DNS, reverse proxy, directory, SSO, Git, and backup — running as rootless Podman containers and managed through Portainer.
It is built for internet-connected homelabs but stages every artifact ahead of time, so the same playbooks work against a Proxmox host with no egress at all.
These documents describe the intended design. The codebase does not yet match it in several places. See docs/known-gaps.md for the current divergences.
| Document | What's in it |
|---|---|
| CONTEXT.md | Glossary — the words this project uses and what they mean |
| docs/preconditions.md | What must be true before you run anything |
| docs/architecture.md | How the pieces fit and what depends on what |
| prestage.md | Prestage runbook |
| docs/runbooks/sneakernet-refresh.md | Monthly certificate refresh for disconnected sites |
| docs/adr/ | Why things are the way they are |
| docs/known-gaps.md | Where the code disagrees with the design |
Sophon runs in two phases, and the split is deliberate.
Prestage is the only part that touches the internet. It pulls container
images, builds the CoreDNS Docker Discovery image, and builds the Alpine NFS VM
disk, writing everything into artifacts/.
Deploy fetches nothing. It provisions Proxmox and brings up every service using only what Prestage produced.
Prestage always runs, even when the target has full internet access — it is the
only thing that keeps the disconnected path working without a second, untested
code path (see
ADR-0001). If you are
connected, site.yml runs it for you and you never think about it. It is a
no-op once artifacts/ is populated, so it costs nothing on later runs and
never reaches the network at a disconnected site.
Run prestage.yml by hand only when the two phases happen on different
machines — stage on a connected host, carry artifacts/ in, deploy there.
nix develop
ansible-playbook site.yml \
-e proxmox_host=10.0.60.2 \
-e proxmox_password=<password> \
-e domain_name=example.com \
-e nfs_ip=10.0.60.10 \
-e infravm_ip=10.0.60.11Stage on a connected machine, copy artifacts/ across, then deploy:
# Connected machine
ansible-playbook prestage.yml
# At the site, after copying artifacts/ into the repo
ansible-playbook site.yml -e ...Read docs/preconditions.md first. Several required inputs — a publicly registered domain, a Cloudflare API token, a Portainer Business Edition licence — are not obvious from the command line.
Generated passwords and the addresses you chose are written to artifacts/ and
reused on later runs, so re-running is safe. Anything passed with -e always
wins and is never written to disk
(ADR-0008).
artifacts/ holds private keys and credentials. It is gitignored, it is a
backup source, and losing it means losing access to a running deployment.
Never paste credentials into tracked files.
Two VMs on Proxmox:
| VM | OS | Role |
|---|---|---|
sophon-nfs |
Alpine | Exports /export — Proxmox storage, container data, artifact staging, Kopia repository |
sophon-infravm |
Fedora CoreOS | Runs every service as a rootless Podman container |
Services on InfraVM, deployed in this order:
| Service | Address | Purpose |
|---|---|---|
| Portainer | https://<infravm_ip>:9443 |
Container management, and the API Ansible deploys through |
| CoreDNS | dns.<domain> |
Authoritative DNS for the zone; discovers containers and maintains tunnel ingress |
| Traefik | traefik.<domain> |
Reverse proxy, TLS termination, ACME |
| OpenLDAP | ldap.<domain> |
Directory |
| Keycloak | auth.<domain> |
SSO, federated against OpenLDAP |
| Gitea | git.<domain> |
Git server, SSO via Keycloak |
| Kopia | kopia.<domain> |
Encrypted backup to the NFS VM over SFTP |
A site with no internet access still deploys. Two features stop working: Cloudflare tunnel access, and automatic certificate renewal. Everything else runs normally on the local network (ADR-0006).
Certificates are then carried in by hand, roughly monthly — see docs/runbooks/sneakernet-refresh.md.
This repository includes a VS Code devcontainer for developers who want the Nix toolchain inside Docker instead of installing Nix directly on their workstation. The container uses Ubuntu as the VS Code base image, installs single-user Nix, and enables flakes so the flake can build the Sophon development shell and Docker-image tarballs without requiring Nix on the host.
Prerequisites on the host:
- Docker or another Docker-compatible engine
- VS Code with the Dev Containers extension
Open the repository in VS Code and choose Dev Containers: Reopen in Container.
The devcontainer installs the flake's sophon-dev-env package into the
vscode user's Nix profile while the image is built, so tools such as
ansible-playbook, ansible-lint, butane, skopeo, and go are available on
the normal container PATH without entering nix develop first. The image build
also installs the Ansible Galaxy collections used by the playbooks.
Rebuild the devcontainer after changing flake.nix or flake.lock so the baked
tool profile is refreshed. nix develop still works inside the container and is
useful when testing shell changes, but it is no longer required for ordinary
Ansible commands or VS Code extension discovery.
The devcontainer sets updateRemoteUserUID to false. This avoids an extra
Dev Containers rebuild stage that can hang on Podman-compatible Docker shims when
they try to resolve the generated local image name as an interactive short name.
The default devcontainer does not mount the host Docker or Podman socket. Docker
hosts usually expose /var/run/docker.sock, while rootless Podman hosts expose a
user-specific socket such as /run/user/1000/podman/podman.sock; assuming either
one can make Dev Containers fail before the workspace opens. Build the image
tarball inside the devcontainer, then load it from a host terminal.
The flake also exposes a Nix-built Docker image containing the Sophon tooling:
nix build .#sophon-runner-imageFrom the host, load and run the image with Docker or Podman:
docker load < result
docker run --rm -it \
--user "$(id -u):$(id -g)" \
-v "$PWD:/workspace" \
sophon-nix-runner:latestUse podman load and podman run with the same arguments on Podman hosts.
This image is a Nix/Nixpkgs-built container image, not a full NixOS boot inside Docker. Docker containers share the host kernel, so use a VM when you need a real NixOS system with its own init, kernel, and system services.
Passwords and tokens are supplied with -e on the command line, or generated
and persisted under artifacts/. Never commit them to a tracked file.
See docs/preconditions.md for the full list of inputs and ADR-0008 for how generated state is stored.
yamllint .
ansible-lint
ansible-playbook --syntax-check site.yml prestage.yml
# Molecule role tests (roles/*/molecule/)
molecule testtests/test.yml holds override values for local integration runs:
ansible-playbook site.yml -e @tests/test.yml.
Let's Encrypt enforces a 5 duplicate-certificates / week rate limit per
identical SAN set. If the traefik_data podman volume is ever recreated
(stack redeploy with prune, host rebuild, etc.) without a backup, the next
few redeploys will burn through that quota and lock TLS issuance for ~7 days.
The traefik role auto-snapshots acme.json after every deploy and seeds it
back into a fresh volume on the next deploy. To take an on-demand backup
between deploys, run the dedicated playbook:
ansible-playbook traefik-backup-acme.yml \
-e domain_name=example.com \
-e infravm_ip=10.0.60.3 \
-e portainer_admin_password=<password>Output: ./artifacts/traefik/acme.json (mode 0600, gitignored). Treat as a
secret — it contains the ACME account private key and all issued cert keys.
Include artifacts/traefik/ in your kopia backup set for offsite recovery.
To force a fresh issuance (e.g. when migrating to staging CA), skip the auto-restore on the next deploy:
ansible-playbook site.yml -e traefik_acme_restore_on_deploy=false ...