Declarative infrastructure for Patchwork Labs: every Patchwork host, the NixOS modules for Patchwork services, the Ansible for the Windows hosts and AD, and their secrets.
| Path | Contents |
|---|---|
flake.nix |
Hosts, exported modules, deploy-rs nodes, checks. |
hosts/<name>/ |
One directory per lab host. |
hosts/proxmox-vm.nix |
Hardware and disk layout shared by every VM. |
hosts/installer.nix |
The installer ISO: NixOS minimal with the admin SSH keys on root. |
bin/install-host |
Plans and applies the VM, reads its address, runs nixos-anywhere. |
bin/new-quilt-staging-secrets |
Writes the two Quilt staging secrets with a new random password and SECRET_KEY_BASE. Prints no value. |
hosts/central-db-schema.nix |
Apps and shared schemas of the central databases. |
hosts/alastor/ |
Modules that jaspermayone/infra imports for alastor. |
modules/common/ |
Accounts, SSH, sudo, Tailscale, Nix. Every host imports it. keys.nix holds the SSH keys. |
modules/ad-join/ |
Joins the host to patchwork.internal with sssd. modules/common imports it. |
modules/central-db/ |
One central PostgreSQL cluster and its access model. |
modules/weave/ |
The Weave service module. |
modules/quilt/ |
The Quilt service module. |
tests/ |
NixOS VM tests. |
secrets/ |
agenix secrets. secrets.nix lists the recipients of each file. |
ansible/ |
Ansible for the Windows hosts and AD: windows.yml, ad.yml. See ansible/README.md. |
terraform/workspace/ |
Terraform for Google Workspace: org units, groups, group settings. See terraform/workspace/README.md. |
ansible/vault/ |
Git submodule patchworklabsorg/infra-vault (private): the Ansible vault. |
ad/ |
The AD structure: org.json and converge.ps1. See ad/README.md. |
terraform/ |
Terraform roots for resources that an API manages. See terraform/README.md. |
workspace/ |
GAM script that sets up Google Workspace. |
terraform/github/ |
Terraform for the patchworklabsorg GitHub org: repos, teams, members, rulesets. See terraform/github/README.md. |
terraform/proxmox/ |
Terraform root for the Patchwork NixOS VMs on pve01. See terraform/proxmox/README.md. |
The lab hosts below are NixOS VMs on pve01, VLAN 90 (patchwork.internal). alastor is the only public edge. It terminates TLS and proxies to the lab hosts over the tailnet.
| Host | VMID | Address | Memory | Runs |
|---|---|---|---|---|
warp |
356 | 10.100.90.56 | 6 GB | Weave prod + staging, future apps. nginx on tailscale0:80 for alastor. |
skein |
355 | 10.100.90.55 | 4 GB | patchwork_central, prod. LAN + tailnet. |
seam |
357 | 10.100.90.57 | 2 GB | patchwork_central_restricted, prod. Tailnet only. |
hem |
358 | 10.100.90.58 | 2 GB | Both staging clusters: central on 5432, restricted on 5433. Quilt staging uses the restricted one. |
The Windows hosts are managed by Ansible (ansible/):
| Host | VMID | Address | Runs |
|---|---|---|---|
loom |
310 | 10.100.90.10 | DC, PDC emulator, DNS, DHCP |
spindle |
311 | 10.100.90.11 | DC, DNS |
bobbin |
312 | 10.100.90.12 | Issuing CA |
shuttle |
314 | 10.100.90.14 | NPS / RADIUS |
VMIDs 356-358 and their addresses are proposed. Confirm that they are free before you create the VMs. pve01 had 121 of 125.8 GiB committed on 2026-08-25, so free about 14 GiB first.
flowchart LR
internet((Internet)) --> alastor["alastor (Traefik, TLS)"]
alastor -- "tailnet, HTTP :80" --> warp
warp -- "LAN :5432" --> skein["skein: patchwork_central"]
warp -- "tailnet :5432" --> seam["seam: patchwork_central_restricted"]
warp -. staging .-> hem["hem: :5432 / :5433"]
Every host joins the patchwork.internal AD forest (modules/ad-join), with the same rules as roles/linux_ad_join in the jaspermayone/infra Ansible:
- Members of
lnx-<host>-userslog in with an SSH key from theiraltSecurityIdentities(SSHKey:<key>). Passwords do not work over SSH. - Members of
lnx-<host>-adminsget sudo with their AD password. - The local accounts in
modules/commonare the break-glass way in.
Staff reach the lab over the thimble VPN (AD login through NPS on shuttle) or over Tailscale.
Before a host is installed:
- Add the host to
hostsinflake.nix. Then createlnx-<host>-usersandlnx-<host>-adminsin AD:cd ansible && ansible-playbook ad.yml -t host-groups. The play reads the host names from the flake. If a group does not resolve, every AD login fails. - Add the host's A record in AD DNS. sssd does not update DNS.
- After the install, add the host key to
secrets/ad-join.ageand runagenix -r.ad-join.servicethen joins on the next boot or deploy.
loom and spindle must run. They are the DNS servers, the KDCs and the time source of every host.
The installer ISO has the admin SSH keys on root. Nobody types in the Proxmox console. The keys come from modules/common/keys.nix, the same file the host accounts use.
- Build the ISO:
nix build .#installer-iso -o result-installer. The build needs an x86_64-linux builder. On macOS, use a Linux builder in/etc/nix/machines, or pass--builders 'ssh-ng://<user>@<linux-host> x86_64-linux'. You can also run the command on a Linux host. - Create or update the VM:
cd terraform/proxmox && tofu apply. Terraform uploads the ISO and boots the VM from it. The guest agent reports the DHCP address. - Read the address:
tofu output ipv4_addresses. If the list is empty, wait one minute, runtofu refresh, and read again. - Install:
nixos-anywhere --flake .#<host> --build-on remote --target-host root@<ip>. - Add the host key to
secrets/ad-join.age. See "Login".
bin/install-host <host> runs steps 2 to 4. It stops if the plan deletes a resource.
See the header of modules/central-db/default.nix for the full model. In short:
- Each app gets a login role and a schema with the same name. The app owns the schema and runs its own migrations there.
- A shared schema (for example
people) has one owner app. Other apps get<schema>_reador<schema>_writethroughhosts/central-db-schema.nix. - Nobody can create objects in
public. Nobody can connect to a database without a grant. - The restricted database is a separate cluster on a separate VM.
To add an app, edit hosts/central-db-schema.nix, add its password secrets, and deploy the DB hosts.
This repo is public. Nothing here depends on jaspermayone/infra, which is private, so every org admin can build this repo.
flowchart LR
pinfra["patchworklabsorg/infra (this repo)"]
jinfra["jaspermayone/infra (private)"]
pinfra -- "nixosModules.weave-alastor, quilt-alastor" --> jinfra
jinfra -- "deploy-rs" --> alastor
pinfra -- "deploy-rs" --> lab["warp, skein, seam, hem"]
| Repo | Owns |
|---|---|
patchworklabsorg/infra |
Every Patchwork host, Patchwork services and their secrets. |
patchworklabsorg/core |
Archived. It held the Core VM, which was never built; warp does that job. |
patchworklabsorg/dns |
Patchwork DNS. |
patchworklabsorg/infra-vault |
Private. The Ansible vault, as the submodule ansible/vault. |
jaspermayone/infra |
Jasper's hosts (alastor and others) and the lab's physical layer. It imports Patchwork services from this repo as the flake input patchwork-infra. |
alastor is Jasper's VPS. jaspermayone/infra owns the host. This repo owns everything about Weave on it: the module, the instance settings, the CI deploy key, the Traefik routes and the secrets.
To change Weave on alastor:
- Merge the change here.
- In
jaspermayone/infra, runnix flake update patchwork-infra. - Deploy alastor from
jaspermayone/infra(deploy .#alastor).
alastor also rebuilds itself at 04:00 from jaspermayone/infra main. It uses the revision in that repo's flake.lock, so a merge here does nothing on alastor until step 2.
Quilt runs on alastor as two Docker containers, like Weave. hosts/alastor/quilt.nix is the instance, exported as nixosModules.quilt-alastor. modules/quilt is the service module. The contract with the app is docs/deploy.md in the quilt repo.
| Item | Value |
|---|---|
| URL | https://quilt.patchworklabs.org (Traefik router quilt, option traefik.hosts) |
| Units | quilt-web (./bin/rails server on 127.0.0.1:3005, migrates on start) and quilt-worker (bundle exec good_job start) |
| Image | ghcr.io/patchworklabsorg/quilt:main (option image). Pin it to :<sha> for a known build. |
| Database | patchwork_central_restricted on seam, role and schema quilt, over the tailnet |
| Secrets | quilt-env.age, quilt-ghcr-token.age (alastor), restricted-prod-quilt.age (seam) |
flowchart LR
internet((Internet)) --> traefik["alastor: Traefik"]
traefik -- "127.0.0.1:3005" --> web[quilt-web]
worker[quilt-worker] -- "waits for /up" --> web
web -- "tailnet :5432" --> seam["seam: patchwork_central_restricted, schema quilt"]
worker -- "tailnet :5432" --> seam
Both containers use the host network, so they reach seam.wildebeest-stargazer.ts.net through alastor's Tailscale. The worker waits until GET /up on the web server answers. The web server answers only after the migrations ran.
RESTRICTED_DATABASE_URL in quilt-env.age holds the database password. It must be the same password as in restricted-prod-quilt.age, which seam sets on the role quilt.
To deploy a new main image, run sudo systemctl restart quilt-web on alastor. That restarts the worker too. To change the module, follow the same steps as for Weave above.
Quilt staging is a second instance on alastor, next to production. It uses the Mercury sandbox, Weave staging and the restricted staging cluster on hem. It can never move real money: with RAILS_ENV=staging, Quilt refuses to boot unless MERCURY_API_BASE is the sandbox URL (patchworklabsorg/quilt#35). The option is staging in modules/quilt. hosts/alastor/quilt.nix turns it on.
| Item | Production | Staging |
|---|---|---|
| URL | https://quilt.patchworklabs.org |
https://quilt-staging.patchworklabs.org (router quilt-staging) |
| Units | quilt-web, quilt-worker |
quilt-staging-web, quilt-staging-worker |
| Port | 127.0.0.1:3005 |
127.0.0.1:3006 |
RAILS_ENV |
production |
staging |
| Image | ghcr.io/patchworklabsorg/quilt:main (option image) |
ghcr.io/patchworklabsorg/quilt:main (option staging.image) |
| Mercury | https://api.mercury.com/api/v1/, live tokens |
https://api-sandbox.mercury.com/api/v1/ (set in Nix), sandbox tokens |
| Weave | Weave production | https://weave-staging.patchworklabs.org (set in Nix), a Weave staging app |
| Database | seam:5432, patchwork_central_restricted |
hem:5433, patchwork_central_restricted (the restricted staging cluster) |
| Env file | quilt-env.age |
quilt-staging-env.age |
| Role password | restricted-prod-quilt.age (seam) |
restricted-staging-quilt.age (hem) |
| Registry token | quilt-ghcr-token.age |
The same file |
flowchart LR
internet((Internet)) --> traefik["alastor: Traefik"]
traefik -- "127.0.0.1:3006" --> web[quilt-staging-web]
worker[quilt-staging-worker] -- "waits for /up" --> web
web -- "tailnet :5433" --> hem["hem: restricted-staging container, schema quilt"]
worker -- "tailnet :5433" --> hem
worker -- sandbox only --> mercury["api-sandbox.mercury.com"]
web -- OIDC --> weave["weave-staging.patchworklabs.org"]
MERCURY_API_BASE and WEAVE_ISSUER are in staging.extraEnv, not in the env file. extraEnv overrides the env file, so a value in the env file cannot point staging at the live Mercury API.
The database URL in quilt-staging-env.age:
RESTRICTED_DATABASE_URL=postgres://quilt:<password>@hem.wildebeest-stargazer.ts.net:5433/patchwork_central_restricted?sslmode=disable
<password> must be the value in restricted-staging-quilt.age. hem decrypts that file to /run/central-db-passwords/patchwork_central_restricted/quilt and binds the directory into the restricted-staging container, where central-db-setup sets the password on the role quilt. hosts/hem/configuration.nix explains why hem does not use /run/agenix for this. hem has no server TLS. Tailscale encrypts the path, so the URL uses sslmode=disable.
To deploy a new main image to staging, run sudo systemctl restart quilt-staging-web on alastor. That restarts the staging worker too.
Do the steps in this order.
- Merge patchworklabsorg/quilt#35. Wait until
image.ymlpushesghcr.io/patchworklabsorg/quilt:main. An older image has nostagingenvironment and does not boot. - Free 2 GiB of memory on pve01. Make sure that VMID 358 and
10.100.90.58are free. - Prepare AD for hem. Run
cd ansible && ansible-playbook ad.yml -t host-groups. Add the A record for hem in AD DNS. See "Login". - Create the VM:
cd terraform/proxmox && tofu apply -target='proxmox_virtual_environment_vm.nixos["hem"]'. Read the address withtofu output ipv4_addresses. - Install hem:
nixos-anywhere --flake .#hem --build-on remote --target-host root@<ip>. - Join hem to the tailnet. Make sure that the tailnet policy lets alastor reach
hem:5433. - Get the host key:
ssh-keyscan -t ed25519 10.100.90.58. Add it asheminsecrets/secrets.nix. Addhemtorestricted-staging-quilt.ageandad-join.age. Remove the TODO. - Create the secrets:
nix develop -c bin/new-quilt-staging-secrets. The script writesrestricted-staging-quilt.ageandquilt-staging-env.agewith a random password andSECRET_KEY_BASE. It leaves the Weave client and the Mercury tokens empty. - In Weave staging (
https://weave-staging.patchworklabs.org), register Quilt as a confidential app:- Redirect URI:
https://quilt-staging.patchworklabs.org/auth/callback. - Scopes:
openid profile email roles directory introspect. A superadmin allowsdirectoryandintrospect. - App roles:
viewer,finance,admin.
- Redirect URI:
- In the Mercury sandbox dashboard (Settings, API Tokens), create a read-only token. Staging refuses to boot without
MERCURY_API_TOKEN. - Add the values:
cd secrets && agenix -e quilt-staging-env.age. SetWEAVE_CLIENT_ID,WEAVE_CLIENT_SECRETandMERCURY_API_TOKEN. KeepQUILT_TOPUPS=off. - Re-key:
cd secrets && agenix -r. Commit the.agefiles and merge. - Deploy hem:
deploy .#hem. In the container, make sure thatcentral-db-setupsucceeded and does not log "no password file for quilt":sudo nixos-container run restricted-staging -- journalctl -u central-db-setup. - From alastor, test the connection:
psql "<RESTRICTED_DATABASE_URL>" -c 'SHOW search_path'. The result must bequilt. - Merge the DNS record
quilt-staging(A129.213.163.213, proxied): patchworklabsorg/dns#11. - In
jaspermayone/infra, runnix flake update patchwork-infraand deploy alastor. - On alastor, check
systemctl status quilt-staging-web quilt-staging-workerandcurl -i http://127.0.0.1:3006/up. Then sign in athttps://quilt-staging.patchworklabs.org.
To test top-ups on staging, add a sandbox MERCURY_WRITE_TOKEN with alastor's IP on its allowlist, and set QUILT_TOPUPS to approval_only.
nix flake check
nix fmt
nix build .#checks.x86_64-linux.central-db # VM test; needs a Linux builder with kvm
deploy .#<host> # deploy-rs over the tailnet
cd secrets && agenix -e <name>.age # create or edit a secret
cd secrets && agenix -r # re-key after changing recipients
git submodule update --init ansible/vault # the Ansible vault (private repo)
cd ansible && ansible-playbook windows.yml # Windows baseline
cd ansible && ansible-playbook ad.yml -t audit # AD report, changes nothing