packages/docs is a SvelteKit site where every page is a +page.md compiled by @immich/svelte-markdown-preprocess into @immich/ui components, deployed to Cloudflare Pages the way static-pages deploys the immich.app sites (tf/pages/docs; main -> docs.futo.cloud, every PR gets a preview). The content is written for beta users: joining the beta, setting up in Immich or the standalone container, the recovery key, backups, schedules, restores, the account dashboard, troubleshooting and support. Internal and developer documentation stays in docs/ and the per-directory READMEs.
yucca/tf
Terraform/OpenTofu authority for cluster identity, 1P secret items, and the inventory artifacts Ansible consumes. Multi-partition / multi-region via terragrunt.
The partition / region model
Everything is keyed on a single first-class topology:
partition → region → { exactly one K8s cluster, one-or-more Ceph clusters }
- Partition =
prod|staging|dev(formerlyenv). - Region = a site:
htz-fsn1,austin,local(formerlysite/datacenter). Plus a reservedglobalpseudo-region for partition-wide stacks (DNS, the account-wide NetBird layer) —globalis not a physical site, so itsrole/FQDN metadata is null. - The human form is
partition@region(prod@htz-fsn1); the canonical slug ispartition-region(prod-htz-fsn1), which derives names on every surface.
Every stack sits at three path segments:
deployment/<partition>/<region>/<stack>. The account-wide prod layer is
prod/global/netbird (a stack under the global region), so there is no
two-segment special case.
Layout
tf/
├── .env ← op:// references (committed; no literal secrets)
├── op-run.sh ← op-run wrapper used by the mise tf:* tasks
├── shared/
│ └── modules/
│ ├── ceph-cluster/ ← per-cluster ceph orchestration module
│ │ ├── main.tf, variables.tf, outputs.tf, rendering.tf
│ │ ├── wordlist.txt ← 923 words for auto-picked hostnames
│ │ └── templates/
│ │ ├── inventory.ini.tftpl
│ │ ├── inventory-destroy.ini.tftpl
│ │ ├── inventory-provision-debian-live.ini.tftpl
│ │ └── secrets.yml.tpl.tftpl
│ └── talos-baremetal/ ← Talos on bare-metal nodes already in maintenance mode
│ ├── main.tf, variables.tf, outputs.tf
│ └── firewall.tf ← Talos host ingress firewall (default-deny + allow-lists)
└── deployment/
├── terragrunt.hcl ← root: state backend + partition/region/stack from path
├── staging/
│ ├── austin/
│ │ ├── region.hcl ← role + FQDN parts for staging@austin
│ │ ├── ceph/ ← sietch ceph cluster (clusters.auto.tfvars)
│ │ └── talos/ ← bare-metal Talos cluster (3× CP, Cilium CNI)
│ └── global/
│ ├── region.hcl ← role=null (pseudo-region)
│ ├── dns/ ← Cloudflare records (futo.cloud)
│ └── netbird/ ← NetBird Cloud access control (staging)
├── dev/
│ ├── local/
│ │ ├── region.hcl
│ │ └── talos/ ← Talos VMs on the sietch hypervisors
│ └── global/
│ ├── region.hcl
│ └── dns/
└── prod/
├── htz-fsn1/
│ ├── region.hcl ← role + FQDN parts; site_id=40
│ ├── mgmt-hosts.yaml ← mgmt-host roster (region root; fabric + render read it)
│ ├── fabric/ ← Junos switch fabric + NetBox + mgmt reprovision
│ └── netbird/ ← htz-fsn1 site NetBird layer (routed htz-fsn1 network)
└── global/
├── region.hcl
├── terragrunt.hcl ← account-wide prod NetBird (two-segment region-root stack)
└── netbird.tf
Add a region: create deployment/<partition>/<region>/region.hcl + stacks under
it. Add a stack: a new sibling dir under a region (<region>/monitoring/ …).
NetBird Cloud access control lives in staging/global/netbird/, and for prod is
layered: prod/global/ (account-wide) above per-region
prod/<region>/netbird/ (e.g. prod/htz-fsn1/netbird/). See "The netbird-env
module" below.
The dns stack manages infrastructure names in the futo.cloud Cloudflare
zone (today: the Sietch RGW S3 endpoint + virtual-hosted wildcard).
Records are declarative in records.auto.tfvars; the API token resolves
from op://yucca_tf_manual/CLOUDFLARE_API_TOKEN via tf/.env.
The talos stack is documented in ansible/talos/README.md and
ansible/talos/docs/runbooks/cluster-bring-up.md (the TF + Ansible flow
is interleaved — TF renders the inventory Ansible consumes, then
bootstraps the VMs Ansible created).
Conventions
Partition, region, and stack are derived from the directory path
deployment/terragrunt.hcl parses the child's relative path:
partition = segs[0], region = segs[1], stack = join(segs[2:]), and
slug = "<partition>-<region>".
deployment/staging/austin/ceph → partition=staging, region=austin, stack=ceph
deployment/staging/global/dns → partition=staging, region=global, stack=dns
deployment/prod/htz-fsn1/fabric → partition=prod, region=htz-fsn1, stack=fabric
deployment/prod/htz-fsn1/netbird → partition=prod, region=htz-fsn1, stack=netbird
deployment/prod/global/netbird → partition=prod, region=global, stack=netbird
The state backend key is derived from these:
yucca/${partition}/${region}/${stack}/terraform.tfstate in the shared
yucca-tf-state S3 bucket. (The legacy ceph/ project prefix is dropped —
talos/dns/netbird/fabric all share the bucket now.) Every stack is three
segments — the account-wide prod layer is prod/global/netbird (not a bare
prod/global), so region.hcl at prod/global/ is found by
find_in_parent_folders from the stack dir, exactly like staging/global. The
n==2 branch in terragrunt.hcl is now dead and can be removed.
Per-region metadata + role (region.hcl)
Each region dir carries a deployment/<partition>/<region>/region.hcl holding
role (primary | secondary), site_id, datacenter, provider_code, and
domain. The root terragrunt finds it via
find_in_parent_folders("region.hcl", "") (with a not-found guard) and merges
its locals into every stack's inputs — so every stack in a region inherits the
metadata without per-tfvars duplication. global pseudo-regions set role = null
(and null FQDN parts). Each stack declares matching variable blocks with null
defaults.
role encodes the product invariant: when a partition spans multiple regions,
yucca-api + the database run only in the primary region; secondary regions
run the storage-local subset. It is authoritative here in TF state
(discovery.role) and consumed downstream (Flux role components, yuctl).
The op run --env-file=tf/.env -- pattern
tf/.env holds 1Password op:// references — not literal secrets:
export OP_SERVICE_ACCOUNT_TOKEN="op://yucca_tf_dev/yucca_futo_1pass_superuser_service_account/password"
Wrap every terragrunt invocation with op run --env-file=tf/.env -- (the
mise tf:* tasks do this automatically). The op CLI resolves the op://
reference and injects the actual token as OP_SERVICE_ACCOUNT_TOKEN into
the child process's environment. The 1P Terraform provider picks it up
from the env var and authenticates.
The same pattern is used in immich-app/devtools and is the Futo-wide
convention for TF secret injection.
Committed .env is safe because it's just pointers
Yucca's root .gitignore normally excludes .env files — we add an
explicit !tf/.env exception. This file contains only op:// URIs; no
secret ever transits the repo. It's a committed manifest of "which 1P
items this TF depends on."
Stack override via TF_STACK_DIR
The default mise run tf:* tasks target tf/deployment/staging/austin/ceph.
Point them at another stack via the TF_STACK_DIR env var:
TF_STACK_DIR=tf/deployment/staging/austin/ceph mise run tf:plan
TF_STACK_DIR=tf/deployment/staging/austin/talos mise run tf:apply
Running TF
One-shot (preferred for now)
mise run tf:init # first time in a stack
mise run tf:plan # dry run
mise run tf:apply # render artifacts + (future) create 1P items
These wrap: op run --env-file=tf/.env -- terragrunt --working-dir <stack> <cmd>.
In CI (staging stacks)
.github/workflows/infra.yml runs the staging stacks from GitHub Actions:
- Plan on every PR touching
tf/**; apply on merge tomain, gated behind thestaging-infraEnvironment (required reviewers). - Applies
staging/austin/talos(cluster + Flux + secrets) thenstaging/global/dns. - The Talos stack reaches the
10.10.10.0/24nodes by joining the tailnet (tailscale/github-action,--accept-routes) — the cluster firewall already trusts the Tailscale CIDRs. The DNS stack is pure Cloudflare API, no tailnet. - Secrets come from the same
op run --env-file=tf/.envpath; CI just supplies the per-env 1P service-account token (the rest resolves from 1P). The token is injected asOP_SERVICE_ACCOUNT_TOKENfrom the environment-specific secret —OP_TF_YUCCA_STAGING_ENVhere (dev/prod workflows useOP_TF_YUCCA_DEV_ENV/OP_TF_YUCCA_PROD_ENV) — replacing a shared superuser SA with a scoped one.
Prerequisites (out-of-band): repo secret OP_TF_YUCCA_STAGING_ENV — a staging
1P service account mirroring the dev/prod ones, i.e. granted shared_tf,
shared_tf_staging, yucca_tf (read) + yucca_tf_staging (read/write for the
JWT-keypair item). Note: a copy of the dev SA token won't work — it can't read
yucca_tf_staging. Also: TS_OAUTH_CLIENT_ID, TS_OAUTH_SECRET; a Tailscale
subnet router advertising 10.10.10.0/24 with tag:project-yucca approved for
it; and the staging-infra Environment with required reviewers.
State backend
Remote: shared yucca-tf-state S3 bucket at OVH Paris
(https://s3.eu-west-par.io.cloud.ovh.net/). Key path:
yucca/${partition}/${region}/${stack}/terraform.tfstate (region-root stacks:
yucca/${partition}/${region}/...). All yucca stacks live under the yucca/
prefix in the shared bucket.
Credentials are AWS-compatible env vars (AWS_ACCESS_KEY_ID /
AWS_SECRET_ACCESS_KEY), injected via op run --env-file=tf/.env from
the TF_STATE_S3_* items in the yucca_tf vault. OVH-specific config
(skip AWS validation, path-style URLs) is in
deployment/terragrunt.hcl.
State locking is not enabled. OVH has no DynamoDB equivalent;
OpenTofu's use_lockfile = true option would handle single-bucket locking
but expects the lockfile object to already exist — terragrunt init
against a fresh backend fails with 404 before it can create one. Enable
it once the concern is concurrent applies (multiple operators working the
same stack simultaneously). Single operator today → low risk.
Discovery outputs contract
Every stack emits a single non-sensitive top-level discovery output (plus
discovery_schema_version). It is the machine-readable description of the
topology that yuctl consumes — it reads the state object straight from S3 and
parses .outputs.discovery.value, so no checkout, init, or provider is needed.
Secrets are always op:// references, never values. The in-stack sensitive
kubeconfig/talosconfig outputs stay for in-stack use; discovery only carries
the reference (op://<vault>/<title>/password).
Common envelope (every stack):
{
schema_version, partition, region, slug, role, stack, stack_type,
region_meta: { site_id, datacenter, provider_code, domain }
}
Per stack_type payload:
| stack_type | stacks | payload key + fields |
|---|---|---|
region-k8s |
*/talos |
kubernetes: { cluster_name, api_endpoint, operator_endpoint, cp_node_ips, kubeconfig_ref, talosconfig_ref } |
ceph |
*/ceph |
ceph_clusters: { <name> => { cluster_name, fqdn, rgw_s3_endpoint, health_cred_ref, s3_admin_cred_refs, secret_item_titles, bootstrap_host } } |
dns |
*/dns |
dns: { provider, zone, record_fqdns, api_token_ref } |
netbird |
*/netbird, prod/global |
netbird: { name_prefix, vault, group_ids, policy_ids, network_ids, setup_key_item_titles } |
fabric |
prod/htz-fsn1/fabric |
fabric: { site_id, kube_cidr, mgmt_cidr, cluster_cidrs } |
The talos stacks persist their kube/talosconfig into 1Password
(onepassword_item, mirroring the JWT-keypair / netbird-setup-key pattern) so
the *_ref fields resolve — staging writes YUCCA_STAGING_{KUBE,TALOS}CONFIG;
dev writes YUCCA_DEV_<CLUSTER>_{KUBE,TALOS}CONFIG.
Migrating an existing stack to the new layout
The dir moves + terragrunt rewrite change the state key (ceph/<env>/... →
yucca/<partition>/<region>/...), so a live stack's remote state must be
re-pointed — reversibly, no destroy/recreate. Per stack (single-operator window;
no state locking):
- Pre-flight
terragrunt planon the OLD layout → confirm no-op; back up the old state object. - Land the terragrunt.hcl rewrite + dir moves + renames (no apply yet).
- Server-side S3 copy old key → new
yucca/...key (old object stays as a rollback anchor). - Delete the stale generated
backend.tf;terragrunt initin the new dir (decline the state-copy prompt;-reconfigureif needed). terragrunt plan→ must be no-op (the gate; a non-empty plan = a rename/source-path mismatch, not a state problem — STOP + roll back).terragrunt output discovery→ confirm the contract resolves.- After all stacks are green,
aws s3 rmthe legacyceph/*objects.
Order: staging first, then prod (global → region → fabric — prod/global
before prod/htz-fsn1/netbird, which has a real terragrunt dependency on it).
dev is dir-move only (no remote state). The NetBird plans must show no
resource renames — the rendered names are byte-identical across the rename.
The ceph-cluster module
Declarative input in clusters.auto.tfvars:
clusters = {
sietch = {
domain = "staging.austin.int.futo.cloud"
partition = "staging"
region = "austin"
provider_code = "int"
role_in_hostname = "ceph"
ansible_ssh_user = "ansible-iac"
ansible_ssh_key = "~/.ssh/id_ed25519_sietch"
vault = "yucca_tf_staging"
provision_profile = "debian-live" # null for Hetzner-installimage clusters
hosts = [
{ name = "laurel", bond_ip = "10.10.10.90", bootstrap = true },
{ name = "lawson", bond_ip = "10.10.10.91" },
{ name = "samara", bond_ip = "10.10.10.92" },
]
}
}
On apply, the module:
- Picks wordlist names for
hosts[].name == null(stable across applies; seeded per-cluster; operator-declared names excluded from the pool to prevent collisions). - Renders
inventory.ini(normal ops),inventory-destroy.ini(explicit destroy flag),secrets.yml.tpl(op:// references toyucca_tf_staging/<CLUSTER>_CEPH_*_PASSWORD/password). Optionally rendersinventory-provision.iniwhenprovision_profile != null. - (Not yet TF-managed)
onepassword_itemresources for cluster secrets are dormant — items are created viaopCLI today and read by Ansible at play time. Seeansible/ceph/docs/secrets.mdfor the re-enable plan.
The talos-baremetal module (staging/austin/talos)
Brings up Talos on bare-metal nodes already running in maintenance mode at
known addresses — no Ansible, no hypervisors, no VLANs (the earlier VM-oriented
talos-cluster module was removed unused). It dials each node's maintenance IP, applies machine
config (which installs to disk + reboots), bootstraps one CP, then emits
kube/talosconfig and gates on cluster health.
Declarative input in deployment/staging/austin/talos/clusters.auto.tfvars:
clusters = {
yucca-staging = {
talos_version = "1.13.4"
kubernetes_version = "v1.36.1"
install_disk = "/dev/sda" # WIPED — the 240GB DELLBOSS; NVMe left raw
cluster_vip = "10.10.10.15" # L2 VIP, etcd-elected across CPs
gateway = "10.10.10.1"
subnet_cidr = "10.10.10.0/24"
cni = "cilium" # cni:none in Talos + Cilium via Helm
disable_kube_proxy = true # Cilium kube-proxy replacement (KubePrism)
cilium_version = "1.19.5"
hubble = true
bond = { interfaces = ["eno1np0", "eno2np1"], mode = "active-backup" } # flip to 802.3ad after the switch is LACP'd
nodes = [
{ name = "staging-cp1", address = "10.10.10.47" },
{ name = "staging-cp2", address = "10.10.10.242" },
{ name = "staging-cp3", address = "10.10.10.117" },
]
}
}
Notes:
- Static IP = maintenance IP. Each node's
addressis pinned as the static IP onbond0, so TF stays reachable across the install reboot. - bond comes up
active-backup(no switch config needed). Migrate to802.3adlater, node-by-node, after converting the switch ports to LACP port-channels — LACP needs both ends configured at once, so a big-bang flip drops connectivity until both sides agree. - Ingress firewall (
firewall.tf): default-deny + per-service allow-lists scoped to the subnet (+ pod CIDR on kubelet). apid + apiserver also trust the Tailscale ranges (trust_tailscale). ⚠️ The host runningtf applymust have a source IP inside an allowed range or apid (50000) is blocked and bootstrap hangs — add operator/jump subnets totrusted_cidrs. - CNI is installed in the same apply. With
cni:nonethe nodes are NotReady until Cilium lands, so the module's health gate runsskip_kubernetes_checks;helm.tfinstalls Cilium, then a second (full) health gate enforces Ready. - One cluster per stack. The helm provider binds to a single cluster
(
one(...)); add more clusters in their own stack.
Run it (see "Running TF" below — needs 1Password unlocked + an on-LAN apply host):
TF_STACK_DIR=tf/deployment/staging/austin/talos mise run tf:init
TF_STACK_DIR=tf/deployment/staging/austin/talos mise run tf:plan
TF_STACK_DIR=tf/deployment/staging/austin/talos mise run tf:apply # WIPES /dev/sda, installs Talos
The netbird-env module (NetBird Cloud access control)
Manages one layer's NetBird Cloud footprint: groups,
access policies, device auth (setup) keys, and routed networks. One
NetBird Cloud account (api.netbird.io) backs everything — the module namespaces
every object <name_prefix>_<key> (all underscores) so all envs/sites coexist.
Group model
Per env (and per prod site), the baseline groups are:
| group | who | rendered (staging / prod htz-fsn1) |
|---|---|---|
ci |
ephemeral CI runners | yucca-staging-ci / yucca-prod-htz-fsn1-ci |
mgmt |
management nodes (configured via Ansible; also the route peers) | yucca-staging-mgmt / … |
talos |
Talos cluster nodes | yucca-staging-talos / … |
k8s_operator |
in-cluster kubernetes operator | yucca-staging-k8s-operator / … |
Logical keys (the tfvars map keys, e.g. ci) stay lowercase; rendered NetBird
names are UPPER_SNAKE (uppercased, hyphens → underscores). CI is per-env
(ci, reaching only that env's groups) — no cross-env CI plane. k8s is split
into talos (the nodes) and k8s_operator (the operator identity) so they can
carry different policies.
Stacks & layering
| stack | partition / scope | state key |
|---|---|---|
deployment/staging/global/netbird |
staging (global region) | yucca/staging/global/netbird/… |
deployment/prod/global/netbird |
prod, account-wide (cross-region) | yucca/prod/global/netbird/… |
deployment/prod/htz-fsn1/netbird |
prod, region htz-fsn1 | yucca/prod/htz-fsn1/netbird/… |
Staging is single-layer. Prod is layered: a global layer (reserved for
account-wide / cross-region groups + policies) above per-region layers. The
global layer is empty today — each region owns its own resource group and the
yucca → resources policy is module-generated per layer (see below). Region
groups are region-scoped (yucca-prod-<region>-<role>) so a network router's
peers are unambiguously that region's mgmt nodes. A region layer can still
consume a global group via a terragrunt dependency on prod/global → the
module's external_groups input, but none do today. The root terragrunt derives stack from the full sub-path,
so prod/htz-fsn1/netbird gets its own state key. (Now that the fabric stack
lives in its own prod/htz-fsn1/fabric/ sub-stack, the region root carries no
terragrunt.hcl, so prod/htz-fsn1/netbird uses a normal
find_in_parent_folders include — the old direct-root-include workaround is
gone.) Rendered NetBird object names and 1P item titles are unchanged by the
rename (YUCCA_STAGING_*, NETBIRD_YUCCA_PROD_HTZ_FSN1_*): the env→partition
/ site→region swap keeps the same string values.
The yucca → yucca-tags access model
yucca members reach everything tagged as a yucca tag (NetBird object names are
lowercase-kebab — e.g. yucca-prod-htz-fsn1-mgmt; the 1Password setup-key item
titles stay UPPER_SNAKE, since CI/ansible/talos read them by op:// string):
yucca— the existing users group (people). External (looked up by its actual nameyucca); never managed here.- yucca tags — any group flagged
resource = truein a layer'sgroups. This marks the group yucca-reachable; it applies to peer/node groups (so a yucca member can SSHyucca-prod-htz-fsn1-mgmt, the mgmt nodes) as well as routed-subnet tags (yucca-prod-htz-fsn1-resources, which the site'snetbird_network_resources are tagged into). Today every group a layer owns is flagged.
For each layer that owns ≥1 yucca tag, the netbird-env module auto-generates
a <prefix>-yucca-to-resources policy (bidirectional = false) whose
destinations are all of that layer's flagged groups — so flagging a group grants
yucca users access to it (its peers and any tagged resources) with no policy to
edit. The source is var.yucca_users_group (default yucca, looked up by name;
set null to opt a layer out). bidirectional = false means yucca users only
initiate — this policy never makes a tagged group a source. The union of these
per-layer policies is the account-wide "yucca reaches every yucca tag we create"
guarantee.
The former single shared
yucca_resourcetag inprod/global(one account-wideyucca → yucca_resourcepolicy, consumed by sites via a terragrunt dependency) was retired in favour of this per-layer model — no cross-stack group reference, and the destination list is derived, not hand-maintained.
Staging additionally grants its ci group access to the existing Liberty
Park infra groups (where the staging nodes live today) — those are external
groups resolved by name in staging/global/netbird/main.tf.
Declarative input (netbird.auto.tfvars)
Groups, setup keys, policies and networks reference groups by logical key, never opaque NetBird IDs:
# resource = true ⇒ yucca-reachable ("yucca tag"); here every group is flagged,
# so yucca users reach all of them (SSH the nodes + the routed subnets)
groups = { ci = { resource = true }, mgmt = { resource = true },
talos = { resource = true }, k8s_operator = { resource = true },
resources = { resource = true } }
setup_keys = {
ci = { type = "reusable", ephemeral = true, auto_groups = ["ci"] }
mgmt = { type = "reusable", auto_groups = ["mgmt"] }
talos = { type = "reusable", auto_groups = ["talos"] }
k8s_operator = { type = "reusable", auto_groups = ["k8s_operator"] }
}
policies = {
ci-to-all = { # CI reaches every node group in this env
rules = [{ name = "ci-to-all", protocol = "all"
sources = ["ci"], destinations = ["mgmt", "talos", "k8s_operator"] }]
}
}
NetBird is default-deny — a peer gets only the access its groups' policies
grant; an empty policies map means total isolation. The yucca → resource
policy is not declared here: the module generates it from every group flagged
resource = true (see the access model above).
Networks (prod htz-fsn1) — CIDRs propagated, not hardcoded
The htz-fsn1 site layer exposes a NetBird Network named htz-fsn1: the
mgmt group are the routing peers, and each routed subnet is a
netbird_network_resource. The CIDRs are derived from the same
fabric-addressing module the fabric stack uses (re-instantiated in the
layer's addressing.tf — a pure, stateless module, so no duplication and no
cross-stack coupling). Every resource is tagged into the site's own resources
group, so access is the module-generated yucca-prod-htz-fsn1-yucca-to-resources
policy. The only per-site input is the site id (the CIDRs flow from it):
site_id = 40 # mirrors prod/htz-fsn1; feeds fabric-addressing → the routed CIDRs
# mgmt 10.40.5.0/24 · api 10.40.10.0/24
# cls1_public 10.40.20.0/23 · cls1_private 10.40.22.0/23
Setup-key plaintext → 1Password. Each setup key's secret key is written to
the per-env vault (yucca_tf_<env>) as item
NETBIRD_<UPPERCASED_NAMESPACED_NAME>_SETUP_KEY (onepassword_item, same "TF
mints secrets into 1P" pattern as the JWT keypair). The namespaced title keeps
multiple prod sites writing to the one yucca_tf_prod vault from colliding.
Auth. Two providers, both fed by op run --env-file=tf/.env[.prod]:
netbird— admin PAT fromNB_PAT(op://shared_tf/NETBIRD_TF_PAT, shared across all envs;management_urldefaults to NetBird Cloud).onepassword—OP_SERVICE_ACCOUNT_TOKEN(same session), writes the keys.
Run it (pure cloud API — no tailnet, no node contact):
TF_STACK_DIR=tf/deployment/staging/global/netbird mise run tf:init # then tf:plan / tf:apply
# prod — global layer first, then each region layer (uses the prod env file + SA):
OP_ENV_FILE=tf/.env.prod TF_STACK_DIR=tf/deployment/prod/global/netbird mise run tf:apply
OP_ENV_FILE=tf/.env.prod TF_STACK_DIR=tf/deployment/prod/htz-fsn1/netbird mise run tf:apply
CI (.github/workflows/infra.yml) applies staging/global/netbird in the staging
matrix, and the prod layers (prod/global then prod/htz-fsn1/netbird) as gated
prod-infra jobs on the prod 1P SA / tf/.env.prod. Prod CI needs the
OP_TF_YUCCA_PROD_ENV[_WRITE] repo secrets + a prod-infra Environment — see
the workflow header.
CI connects over NetBird
CI reaches the staging 10.10.10.0/24 nodes over the NetBird overlay (this
replaced the Tailscale subnet-router path). The .github/actions/netbird-connect
composite action installs the client and runs netbird up with the ci setup
key read from 1P (op://yucca_tf_staging/NETBIRD_YUCCA_STAGING_CI_SETUP_KEY);
the runner joins as a ci peer and the existing staging route advertises the LAN.
The apply job applies staging/global/netbird first (minting that key) before
connecting, so a fresh bootstrap is self-contained. The prod fabric workflow
(fabric.yml) still uses Tailscale — 10.40.5.0/24 isn't on NetBird yet.
Where secrets actually live
yucca_tf_dev(team-shared): live values consumed by Ansible at play time. Password items per cluster (ops, dashboard, grafana, S3 svc-user access + secret), SSH Key items per cluster (ansible-iac keypairs), and DR-capture items per cluster (RGW TLS cert + key, client.admin keyring — populated bymise run capture).yucca_tf_dev_manual(team-shared): placeholders for human-fillable secrets (API tokens, OAuth client secrets). Not yet used by ceph-cluster.
Service accounts themselves are in yucca_tf_dev as two items:
| SA | Purpose | Consumed by |
|---|---|---|
yucca_futo_1pass_superuser_service_account |
Read + write all yucca_tf_* vaults |
TF (via tf/.env) |
yucca_futo_1pass_service_account |
Read-only on yucca_tf and yucca_tf_dev |
Ansible runtime / CI |
Both are shared with other Futo consumers (o11y, base Yucca infra).
Rotation affects all of them — see ansible/ceph/docs/runbooks/rotate-sa-token.md
for the coordination procedure.
Adding a new cluster
- Add an entry to
clusters.auto.tfvars. - Create the 1P items in
yucca_tf_dev:- Password items:
<CLUSTER>_CEPH_{OPS,DASHBOARD,GRAFANA}_PASSWORD, plus<CLUSTER>_CEPH_S3_SVC_YUCCA_RESTIC_{ACCESS,SECRET}_KEY. Useop item create --generate-passwordfor each. - SSH Key item:
<CLUSTER>_CEPH_ANSIBLE_IAC_SSH_KEYviaop item create --category "SSH Key" --ssh-generate-key=ed25519.
- Password items:
mise run tf:apply— renders inventory + secrets template.- Create per-node
host_vars/*.ymlfiles in the new inventory dir (hardware topology; not TF-rendered yet). - On operator workstation:
scripts/install-ssh-keys.sh <cluster>to pull the private key from 1P. - After first successful deploy:
mise run captureto snapshot the RGW TLS material + admin keyring to 1P for DR. - See
ansible/ceph/docs/adding-a-cluster.mdfor the full walk-through.
Related
- TF-first + op inject model:
ansible/ceph/docs/secrets.md - Immich devtools (upstream pattern): https://github.com/immich-app/devtools/tree/main/tf