Phase 2 — Install GitLab + Runner + OpenBao on a Phase-1 cluster¶
This phase installs the application stack on top of the 5-node kind cluster that Phase 1 created. Everything here is idempotent — re-running the install resumes from the failed step and the success path is a no-op.
If you only need to use the stack, the README is
the entry point. This doc is for the team mate who has to modify
or extend Phase 2: add a new component, change a chart value,
debug a failure, or understand why something is the way it is.
1. What Phase 2 delivers¶
After uv run blueprint-bootstrap --phase 2 finishes on a healthy
Phase-1 cluster, you have:
- CloudNativePG operator + Cluster/postgresql-cnpg in the
postgresqlnamespace — single instance, 8Gi, bound to the stable PV via a pre-created PVC namedpostgresql-cnpg-1. The bootstrap mints thegitlabandopenbaoPG roles (their passwords are persisted toinfra/secrets/cnpg-role-passwords.json, mode 0600) and thegitlabhq_production+openbaodatabases. - Redis single-node in the
redisnamespace (architecture=standalone, no Sentinel, no replicas), bound to the stable PV. Password snapshot atinfra/secrets/redis-password.txt. - MinIO single-node in the
minionamespace — single pod, no distributed mode, no erasure coding. 11 GitLab buckets are created via in-clustermc. Root credentials snapshotted toinfra/secrets/minio-root-{user,password}.txt. A dual-keygitlab-rails-storageSecret in thegitlabnamespace carries both the Rails-sideconnectionand the Docker-registry-nativeconfig(see § Stable storage / MinIO object store below). - OpenBao in the
openbaonamespace — the bootstrap-installed OpenBao, initialised + unsealed, KV-v2 mount atsecret/, root token + unseal key persisted toinfra/secrets/openbao-init.json(gitignored, mode 0600). Used by the bootstrap itself as a hand-off point (OpenBaoClient+blueprint-secretsCLI). - Wildcard TLS — self-signed CA + wildcard cert for
*.local.example.netmaterialised asgitlab-wildcard-tls(plus 3 listener-specific aliasesregistry-tls/kas-tls/minio-tls) in thegitlabnamespace. - GitLab CE in the
gitlabnamespace. Chart 10.x sub-installs Envoy Gateway as a managed sub-chart (gateway-helm) and bundles OpenBao as a managed sub-chart (chart-bundled OpenBao→gitlab-openbaoDeployment; the GitLab rails app uses it for Secrets Manager). The chart also re-uses the externally-installed CloudNativePG, Redis, and MinIO viaglobal.psql.host,global.redis.host, andappConfig.object_store.*.connection.secret. - GitLab Runner in
gitlab(sub-chart) registered against GitLab using a token that was captured into OpenBao atsecret/gitlab/runner/registration_tokenduring the GitLab install step. The Runner registers againsthttp://gitlab-webservice-default.gitlab.svc:8181(in-cluster Service DNS, not the public hostname — see § Runner URL below). - All the secrets a developer needs to bootstrap the cluster in
their
/etc/hosts+ browser, in OpenBao — not in git, not on disk outside the gitignored init JSON.
URLs the user can reach¶
All served by the chart's Envoy Gateway, terminated with the
self-signed wildcard. Trust the CA on the host first (see
README).
| URL | Login | What it is |
|---|---|---|
https://gitlab.local.example.net |
root / OpenBao secret |
GitLab web UI + API |
https://registry.local.example.net |
root / OpenBao secret |
GitLab Container Registry |
https://kas.local.example.net |
root / OpenBao secret |
GitLab Agent Server (KAS) |
https://minio.local.example.net |
root / OpenBao secret (Snapshot → MinIO creds) |
MinIO (LFS, artifacts, packages) |
https://openbao.local.example.net |
root token in infra/secrets/openbao-init.json |
Bootstrap-installed OpenBao UI (KV-v2 hand-off) |
The chart-bundled OpenBao is internal-only — it has no public
Gateway listener, it talks to the rails app over
openbao.gitlab.svc:8200 from inside the cluster. Web admin UI is
deliberately not exposed. The Secrets Manager API path lives at
https://gitlab.local.example.net/api/v4/secrets_manager/... once
the admin enables Secrets Manager in the GitLab UI.
2. The 13-step pipeline¶
1. Pre-flight cluster + helm reachable
2. Gateway CRDs standard v1.5.0 + chart-shipped Envoy CRDs
(8 in `gateway-api-crds/generated/`)
3. local-path provisioner rancher/local-path with `local-path` as default
StorageClass + bind pathBase → /var/local/shared
4. Stable PV/PVC pairs pre-create hostPath-backed PVs for the stateful
services we want to preserve across cluster
recreates (CloudNativePG, Redis, MinIO, OpenBao,
Gitaly) — 3 flavors depending on how the chart
mints its PVC. The CNPG case enforces an extra
PVC name + annotation contract (see § Stable
storage).
5. CloudNativePG install the operator + Cluster/postgresql-cnpg
(1 instance, 8Gi, FQDN selector) + bootstrap
`gitlabhq_production` + `openbao` databases
and the corresponding roles
6. Redis install bitnami/redis single-node
(architecture=standalone)
7. MinIO install minio (mode: standalone) + 11 buckets
+ dual-key `gitlab-rails-storage` Secret
8. OpenBao install + init + unseal (PG backend =
CloudNativePG, database `openbao`)
9. Wildcard TLS certs mint a self-signed CA + wildcard cert for
*.local.example.net and materialise the 4 Gateway
listener Secrets. Idempotent: re-uses a cert that
has ≥30 days of validity left.
10. Persistent Secrets restore chart-managed Secrets (postgres/redis/
minio/rails/gitaly/kas) from the host-side
snapshot so the chart sees them already exist
and re-uses the same credentials as the on-disk
data — without this, PG logs
`password authentication failed` after every
cluster recreate.
11. GitLab install chart 10.x (bundles Envoy Gateway +
chart-bundled OpenBao subchart) + token
capture + write initial root password to
OpenBao + re-create the bootstrap-minted
PG roles if the cluster was recreated
without `--destroy`
12. Migrations wait for the chart-managed migrations Job
to complete (skipped on subsequent
re-installs)
13. GitLab Runner install + register against
`http://gitlab-webservice-default.gitlab.svc:8181`
using the token captured by step 11
Each step is one method on Phase2Pipeline (_step_*) and one
XxxInstaller class. The pipeline owns ordering, logging, and
error reporting; each installer owns the actual work.
The pipeline is idempotent at the step level: re-running resumes from the failed step and the success path is a no-op. Idempotency is achieved by:
- probing the cluster (
kubectl get) before doing anything; - reading the init JSON / KV state to detect "already done";
- reading the cert validity to decide whether to re-mint the wildcard;
- reading the host-side Secrets snapshot to decide whether to
restore (
gitlab-runtime-secrets.yamlmissing → fresh install, chart will mint new passwords and the snapshot gets re-written at the end of the install); - using
helm upgrade --install(creates or upgrades, never errors on existing release); kubectl apply --server-side --force-conflictsfor the CRDs and PVs (re-applying is fine, the server resolves conflicts).
Wiping everything¶
The bootstrap ships a one-shot teardown:
Runs tofu destroy (cluster lifecycle is owned by OpenTofu;
see AGENTS.md § 4 rule #3), then recursively wipes the
bootstrap-owned host-side state: infra/data/shared/stable/,
infra/data/shared/*.preserved-* orphan dirs, infra/tls/wildcard/,
infra/secrets/openbao-init.json, infra/secrets/gitlab-runtime-secrets.yaml.
PV dirs that the local-path-provisioner mv'd to *.preserved-<ts>
on past helm uninstalls are also cleaned up. If a stable dir is
owned by a pod UID that our user can't unlink (openbao=100,
postgres=1001), --destroy falls back to a one-shot
docker run --rm (or podman run) bind-mounting the parent
dir, so the privileged container can chmod -R a+rwX && rm -rf
the child. --destroy never recreates — the user must run
tofu apply themselves, per AGENTS.md § 4 rule #1.
3. The big idea: the chart owns the rest¶
Phase 2 used to install Traefik + a custom GatewayClass chart + a custom CA chart + HTTPRoute manifests. It now does none of that — the GitLab chart owns it all:
- The chart's
gateway-helmsub-chart installs Envoy Gateway. - The chart's pre-install cfssl Job mints
*.local.example.net. - The chart's own templates render a
Gateway+ the fourHTTPRouteresources (gitlab / registry / kas / minio) and point them at the Secret the Job created. - The chart's
configureCertmanager: falsesetting tells it to use the self-signed path instead of requiring a public-CA issuer (which can't validate*.local.example.netanyway).
This means Phase 2 has exactly one value override that touches
the TLS path: we patch the chart's Gateway listener
tls.certificateRefs[0].name from the cert-manager default
(gitlab-tls) to the self-signed cert name
(gitlab-wildcard-tls). That override lives in
phase2/references/helm-values-gitlab.yaml and is the only
non-default value the bootstrap puts in for the TLS path.
Why this matters for new contributors: don't add custom
ingress / cert / GatewayClass charts. Anything you might be
tempted to install alongside the GitLab chart almost certainly
has a chart value that turns it on. Read the chart's
values.yaml first.
4. The code map¶
infra/scripts/bootstrap/
├── cli.py # click wrapper: `blueprint-bootstrap` entry point
│ # + `--destroy` / `--port-forward` / `--dry-run`
├── secrets_cli.py # click wrapper: `blueprint-secrets` (post-install helper)
├── app.py # composition root — wires Phase2Installers into BootstrapApp
├── app_installer.py # base class HelmAppInstaller + generic helm plumbing
├── phase2/
│ ├── pipeline.py # Phase2Pipeline — orchestrates the 13 steps
│ ├── catalog.py # Phase2Installers dataclass — bundle of every installer
│ ├── gateway.py # GatewayCRDsInstaller (standard v1.5.0 + 8 chart-shipped Envoy CRDs)
│ ├── local_path_provisioner.py # local-path StorageClass + teardown patches
│ ├── stable_storage.py # pre-create stable PV/PVC pairs (3 flavors + CNPG name/annotation contract)
│ ├── cloudnative_pg.py # CNPG operator + Cluster/postgresql-cnpg + role/db bootstrap
│ ├── redis.py # bitnami/redis single-node installer (architecture=standalone)
│ ├── minio.py # MinIO single-node installer + 11 buckets + dual-key Secret
│ ├── openbao.py # OpenBaoInstaller (chart + init + unseal; PG backend)
│ ├── wildcard_certs.py # mint CA + wildcard cert + 4 Gateway listener Secrets
│ ├── persistent_secrets.py # snapshot + restore chart-managed Secrets
│ ├── gitlab.py # GitlabInstaller (chart 10.x + token capture + write root pw to OpenBao)
│ ├── runner.py # GitLabRunnerInstaller (in-cluster Service URL, HTTP)
│ ├── secrets.py # OpenBaoClient (hvac + auto port-forward)
│ └── references/
│ ├── cluster-postgresql.yaml
│ ├── helm-values-{cnpg,redis,minio,openbao,gitlab,runner}.yaml
│ └── gateway-api-crds/ # generated/ subdir contains 8 chart-shipped Envoy CRDs;
│ # the standard v1.5.0 CRDs are applied directly from URL
Who calls who¶
BootstrapApp (app.py)
└─> Phase2Pipeline (phase2/pipeline.py)
├─> _step_preflight → CommandRunner probes
├─> _step_gateway_crds → GatewayCRDsInstaller
├─> _step_local_path → LocalPathProvisionerInstaller
├─> _step_stable_storage → StableStorageInstaller
│ (3 flavors: existing_claim,
│ volume_claim_template,
│ pvc_with_volume_name)
├─> _step_cloudnative_pg → CloudNativePGInstaller
│ (operator + Cluster + role/db bootstrap)
├─> _step_redis → RedisInstaller
├─> _step_minio → MinIOInstaller
│ (deploy + 11 buckets + dual-key
│ gitlab-rails-storage Secret)
├─> _step_openbao → OpenBaoInstaller
│ └─> OpenBaoClient (init, unseal, kv mounts)
├─> _step_wildcard_certs → WildcardCertsInstaller
├─> _step_persistent_secrets_restore → PersistentSecretsInstaller.restore()
├─> _step_gitlab → GitlabInstaller
│ └─> OpenBaoClient (write root password
│ + capture runner token)
├─> _step_migrations → wait for migrations Job to Complete
└─> _step_runner → GitLabRunnerInstaller
└─> OpenBaoClient (read runner token)
OpenBaoClient is the only stateful helper Phase 2 shares
across steps. The GitlabInstaller writes the GitLab initial
root password and the runner registration token into OpenBao;
the GitLabRunnerInstaller reads the runner token back. Without
this handoff, Runner would have to authenticate against GitLab
at install time with a token we don't have yet.
5. Conventions every contributor must follow¶
These are non-negotiable. They are restated in AGENTS.md § 4
and .agents/skills/provision-phase-2/SKILL.md, but they bear
repeating because every Phase 2 PR will be measured against
them.
-
No
kubectl exec ... bao ...in install code. Phase 2 talks to OpenBao viaOpenBaoClient(hvac over HTTP). The client auto-port-forwards 127.0.0.1:8200 on first use. Direct execs are fragile (the kubelet round-trips every call, and the pod's token helper doesn't always survive across separate exec invocations — that bug bit us once, see SKILL.md common pitfalls). -
All template / config content lives in
phase2/references/. No openssl cnf strings, no inline YAML, no f-strings building chart values. The Python code reads from these files at install time. The reason: changes to install-time config are reviewable as diffs, not embedded in code. -
All pinned versions live in
bootstrap/VERSIONS.json. No class hardcodes a chart version, a container image, or a tool version. Bump versions in JSON, never in code. The bootstrap uses this file as the single source of truth for what gets downloaded and what version of what is installed. -
Chart values are merged, not overridden. Each installer's values file (under
references/) is a partial override layered on top of the chart's ownvalues.yaml. Don't copy the whole chart values into our override — just the keys we want to change. The GitLab chart's defaults are the baseline. -
*.local.example.netis for browsers, not for cluster traffic. Pods that try to reachgitlab.local.example.netresolve it to127.0.0.1and break. Every chart value that takes a*Url/*Host/*Endpointflag and is read by a pod must point at the in-cluster Service DNS instead: gitlab-webservice-default.gitlab.svc:8181openbao.openbao.svc:8200postgresql-cnpg-rw.postgresql.svc.cluster.local:5432
If you add a new chart and find yourself wanting to put
gitlab.local.example.net in a value, stop and use the
Service DNS.
-
Python follows SOLID. One class per concern.
app.pyis the only place that knows about every class.catalog.pyis the only place that knows about every installer. The pipeline knows the order, the installers know the work. -
Dry-run must work end-to-end.
uv run blueprint-bootstrap --phase 2 --dry-runexercises every step without touching the cluster. TheDryRunRunneris the single seam: every command goes through it. If your installer callssubprocess.rundirectly, the dry-run mode will silently execute the real command — fix it by routing through the injectedCommandRunner.
6. How to add a new install step¶
Use case: you want to add Vault (or trivy-operator, or
prometheus, or anything that sits alongside GitLab) so it
installs on the same Phase-1 cluster as part of the same
--phase 2 run.
Checklist¶
- [ ] Add the chart to
bootstrap/VERSIONS.jsonunderhelm_repositories(name, url, chart, chart_version, values_overrides). This is whathelm_cache.pyreads to download the chart on first run. - [ ] Create
phase2/<name>.pywith a<Name>Installerclass that subclassesHelmAppInstaller. See § 7 for the skeleton. - [ ] Create
phase2/references/helm-values-<name>.yamlwith your partial value override. Don't copy the chart's full values — just the keys you need to change. - [ ] If the component needs a Secret in OpenBao, do the
write in
install()viaOpenBaoClient.kv_put(...). If later steps need that Secret, read it back the same way. Never read a Secret directly from a K8s Secret — go through OpenBao. - [ ] Add the installer to
phase2/catalog.py:Phase2Installers(frozen dataclass) +all(). - [ ] Wire the installer in
app.py(where the other Phase 2 installers are constructed — look forself._phase2_installers = Phase2Installers(...)). - [ ] Add a
_step_<name>method toPhase2Pipelineand call it fromrun(). Place it after OpenBao if it needs OpenBao, otherwise anywhere after the pre-flight. - [ ] Update
__init__.pyto re-export the new class. - [ ] If the new component changes the smoke-test surface,
append to the "Smoke tests" block in
.agents/skills/provision-phase-2/SKILL.md. - [ ] If the new component changed any pinned version or made a non-obvious design choice, append to the "Common pitfalls" section of the SKILL.
- [ ] Run
uv run blueprint-bootstrap --phase 2 --dry-runand confirm the new step appears with the right shape.
Step-order rules of thumb¶
- Anything that needs OpenBao → after
_step_openbao. - Anything that needs GitLab → after
_step_gitlab. - CRDs that the GitLab chart depends on → before
_step_gitlab. - Components that the GitLab Runner needs to talk to → after
_step_gitlaband probably after_step_runner(so the runner is registered before the workload starts).
7. Installer skeleton¶
# infra/scripts/bootstrap/phase2/<name>.py
from __future__ import annotations
from ..app_installer import HelmAppInstaller, HelmAppSpec
from ..logger import Logger
from ..paths import Paths
from ..shell import CommandRunner
from .secrets import OpenBaoClient # only if you need it
class VaultInstaller(HelmAppInstaller):
"""One-paragraph description of what this installs + post-install steps.
Idempotency: describe how this installer knows when to do nothing.
"""
NAMESPACE = "vault"
RELEASE = "vault"
REPO_KEY = "vault" # must match a key under helm_repositories in VERSIONS.json
def __init__(
self,
runner: CommandRunner,
paths: Paths,
cache, # HelmChartCache
log: Logger,
openbao: OpenBaoClient | None = None, # inject if you need it
) -> None:
super().__init__(
runner, paths, cache, log,
HelmAppSpec(
repo_key=self.REPO_KEY,
release=self.RELEASE,
namespace=self.NAMESPACE,
wait=True,
values_files=(
str(paths.phase2_refs_dir / "helm-values-vault.yaml"),
),
),
)
self._openbao = openbao
def install(self): # type: ignore[override]
result = super().install() # helm upgrade --install
if self._openbao is not None:
# Post-install: write any secrets the chart needs at boot.
# All output goes through self._log so the dry-run path
# works without touching a real OpenBao.
self._log.info("writing vault init token to OpenBao")
self._openbao.kv_put("vault", {"init_token": "..."})
return result
Three things to be careful of:
paths.phase2_refs_diris the absolute path toinfra/scripts/bootstrap/phase2/references/. Use it for every file the installer reads. Don't hardcode paths.HelmAppSpechas more fields than shown — seeapp_installer.pyfor the full set. The most useful non-obvious one isset_overrides: dict[str, object]for things that can't be expressed in the values file (e.g.set gitlabUrl=...from a runtime-resolved value).super().install()returns theSubprocessRunnerresult (or aDryRunRunnerfake). Don't ignore it — the dry-run smoke test depends on every code path going through the runner.
8. How to change a chart value¶
Three places, in order of preference:
- Add to
phase2/references/helm-values-<chart>.yaml. This is the values fileHelmAppInstallerpasses tohelm upgrade --install. The chart's ownvalues.yamlis the baseline; this file is a partial override on top. - Bump a pinned version. Edit
bootstrap/VERSIONS.jsonunderhelm_repositories.<chart>(chart_versionfor the chart version,values_overridesfor the default override that gets applied even without a per-installer values file). Thehelm_cache.pyre-downloads the chart on the next run. - Pass at install time.
HelmAppSpec.set_overridesis a dict of--setflags. Use this for values that have to be computed at install time (e.g. a token read from OpenBao). Avoid if you can —references/files are reviewable as diffs,--setflags aren't.
The chart's own values.yaml is the documentation of record
for what's settable. Read it before guessing. The GitLab chart
in particular has a deep global.* tree that drives most of
the behaviour; the override we ship
(phase2/references/helm-values-gitlab.yaml) only changes ~10
keys.
9. How to debug a failing step¶
When --phase 2 fails, the message looks like
Phase 2 install failed at step N/9: <error>. The mapping
from step to source file is:
| Step | Source |
|---|---|
| 1/13 | phase2/pipeline.py:_step_preflight |
| 2/13 | phase2/gateway.py + phase2/references/gateway-api-crds/ |
| 3/13 | phase2/local_path_provisioner.py (StorageClass + bind-mount patches) |
| 4/13 | phase2/stable_storage.py (3 flavors: existing_claim / volume_claim_template / pvc_with_volume_name + the CNPG name + annotation contract) |
| 5/13 | phase2/cloudnative_pg.py + phase2/references/cluster-postgresql.yaml (operator + Cluster + role/db bootstrap) |
| 6/13 | phase2/redis.py + phase2/references/helm-values-redis.yaml (bitnami/redis single-node) |
| 7/13 | phase2/minio.py + phase2/references/helm-values-minio.yaml (MinIO + 11 buckets + dual-key Secret) |
| 8/13 | phase2/openbao.py + phase2/secrets.py (OpenBaoClient) + phase2/references/helm-values-openbao.yaml |
| 9/13 | phase2/wildcard_certs.py (CA + cert mint + 4 Gateway listener Secrets) |
| 10/13 | phase2/persistent_secrets.py:restore() + infra/secrets/gitlab-runtime-secrets.yaml (host-side snapshot) |
| 11/13 | phase2/gitlab.py + phase2/references/helm-values-gitlab.yaml (chart-bundled OpenBao subchart + external PG/Redis/MinIO) |
| 12/13 | phase2/pipeline.py:_step_migrations — wait for gitlab-migrations-* Job to reach Complete |
| 13/13 | phase2/runner.py + phase2/references/helm-values-runner.yaml |
In addition:
- The "Common pitfalls" section of
.agents/skills/provision-phase-2/SKILL.mdis the canonical log of every symptom we've seen and how it was fixed. Read it before going deep. If your fix isn't there, append to it (the rule is "append, don't rewrite"). - Dry-run is your friend.
uv run blueprint-bootstrap --phase 2 --dry-runruns every step against theDryRunRunner, which logs every command without executing. If the dry-run shape doesn't match what you expect, the install is going to fail in the same way. - The post-install helper is the easiest way to check
OpenBao.
uv run blueprint-secrets read <path> [key]auto-port-forwards 127.0.0.1:8200 and reads via hvac — nokubectl exec, no token helper fragility.
10. Stable storage (the contract)¶
phase2/stable_storage.py owns the three flavours of stable PV/PVC
pair. Two of them are pre-created PVCs with explicit
claimRef/matchLabels; the third (CNPG) has an extra contract
that you will trip over if you don't know it.
General pairing rules¶
- All PVs use
hostPathpointing toinfra/data/shared/stable/<service>/. The path is created by the bootstrap (mode 0777) and survivestofu destroy. - PVs use
RetainpersistentVolumeReclaimPolicy so the volume isn't reaped on PVC delete. The bootstrap patches the claimRef out of the PV before re-applying, since the UID of the PVC changes per install. - Three flavours depending on how the chart mints its PVC:
| Flavour | Used by | Mechanism |
| --- | --- | --- |
|
existing_claim| Redis, MinIO, CNPG | chart'spersistence.existingClaim: <name>(orspec.selector.matchLabelsfor CNPG) | |volume_claim_template| OpenBao, Gitaly | StatefulSet picks labels on its PVC; PV usesmatchLabels| |pvc_with_volume_name| (legacy) | non-templated PVC with explicitvolumeName: <pv-name>|
CNPG-specific contract¶
The CloudNativePG operator resolves PVC ownership from the
field-index selector .metadata.controller — it lists every
PVC whose metadata.ownerReferences[?(@.controller==true)].uid
matches the Cluster's UID. A pre-created PVC without
ownerReferences is invisible to the operator's reconcile loop:
- PVC name must match
<cluster-name>-<serial>. For our Clusterpostgresql-cnpg(single instance), that'spostgresql-cnpg-1. The bootstrap'sStableVolumedataclass computes the expected name at install time. - Required annotations on the PVC:
cnpg.io/cluster: postgresql-cnpgcnpg.io/instanceName: postgresql-cnpg-1cnpg.io/instanceRole: primarycnpg.io/nodeSerial: "1"cnpg.io/pvcRole: main
ownerReferences[]must contain the Cluster as acontroller: truereference. The operator only trusts thecontroller: trueflag, NOT just any reference. After the Cluster is created (step 5),phase2/cloudnative_pg.pypatches the PVC with a single ownerReference pointing at the Cluster's UID. Without this step the operator logsRefusing to create the primary instance: cluster already initializedin a loop until the GC reclaims the orphan.
Both invariants are owned by the bootstrap. Hand-editing PVCs to work around them is a code smell — the bootstrap should be the one fixing it.
11. MinIO object store + the dual-key Secret¶
phase2/minio.py does three things in sequence:
- Deploy MinIO (
mode: standalone, single pod, no erasure coding) bound to the stable PV. - Create 11 GitLab buckets via in-cluster
mc(gitlab-lfs,gitlab-artifacts,gitlab-uploads,gitlab-packages,gitlab-backups,gitlab-terraform-state,gitlab-ci-secure-files,gitlab-pages,gitlab-dependency-proxy,gitlab-snippets,gitlab-registry). - Materialise the dual-key Secret
gitlab-rails-storagein thegitlabnamespace. Two keys:connection— Rails-side Fog/AWS provider schema (provider: AWS,aws_access_key_id, etc.). Consumed byappConfig.object_store.lfs/artifacts/uploads/.../backupsviaconnection.secret+connection.key.config— Docker-registry natives3:block (bucket,accesskey,secretkey,region,regionendpoint,secure: false,v4auth: true,pathstyle: true,rootdirectory: /). Consumed byregistry.storage.secret+registry.storage.key: config.
Don't merge the two keys — the chart-bundled registry and the
Rails-side parsers want different schemas; one YAML document
that satisfies one will confuse the other. The split is owned
by phase2/minio.py; references/helm-values-gitlab.yaml
points the registry at config (not the chart default
connection).
12. Chart-bundled OpenBao vs bootstrap-installed OpenBao¶
There are two OpenBao deployments in the cluster, with overlapping but distinct roles:
| Bootstrap-installed | Chart-bundled | |
|---|---|---|
| Chart | openbao-0.10.1 (our cached copy in infra/helm-charts/) |
sub-chart of GitLab 10.x |
| Namespace | openbao |
gitlab |
| Workload | StatefulSet/openbao (1 replica, no HA) |
Deployment/gitlab-openbao (2 replicas) |
| Storage backend | PostgreSQL (postgresql-cnpg-rw.postgresql.svc.cluster.local, database openbao, role openbao) |
PostgreSQL (same cluster, same database) |
| Used by | The bootstrap itself (OpenBaoClient), the blueprint-secrets CLI, the developer |
The GitLab rails app, for the GitLab Secrets Manager API |
| Public UI? | YES — Gateway listener https://openbao.local.example.net |
NO — internal Service only |
| Root token | infra/secrets/openbao-init.json |
Part of the chart's chart-managed Secrets (gitlab-openbao-secret, snapshotted to gitlab-runtime-secrets.yaml via persistent_secrets.py) |
The bootstrap-installed OpenBao is the hand-off point for
shared bootstrap state — GitLab's initial root password, the
Runner registration token. The chart-bundled OpenBao is a
runtime dependency of the rails app, and the chart owns its
lifecycle (including its persistence — see
phase2/persistent_secrets.py for how it lands in
gitlab-runtime-secrets.yaml so a cluster recreate keeps the
Secrets Manager content).
The two deployments don't interfere because they're in
different namespaces with different Service names (openbao
vs gitlab-openbao). The chart's global.openbao.install:
true setting is what causes the chart-bundled one to deploy.
13. Runner URL (where the Runner pod registers)¶
phase2/runner.py installs the chart-bundled GitLab Runner
sub-chart with:
gitlab-runner:
gitlabUrl: http://gitlab-webservice-default.gitlab.svc:8181
unregisterRunners: false
runners: { ... }
Why these particular values:
gitlabUrlishttp://on port 8181, nothttps://on 443 or the public hostname. Port 8181 is the chart's in-cluster workhorse port; it speaks plain HTTP. TLS terminates at the Envoy Gateway above it. Routing the pod throughgitlab.local.example.net:443would require the pod to resolve that hostname (no CoreDNS rewrite inside the cluster → resolves to 127.0.0.1 → broken) AND trust the wildcard CA (extra config). Neither is worth the trouble for what is a privileged, in-cluster call.unregisterRunners: falsekeepshelm upgradefrom wiping the registration token on every chart upgrade. The default behaviour is "unregister all runners on helm uninstall/upgrade", which would make everyhelm upgrade gitlabround-trip re-register the runner (and break in-flight jobs).
If you find yourself wanting to change gitlabUrl, don't
flip it back to the public hostname — see the in-cluster rule
in AGENTS.md § Other rules. The current value is the result
of a multi-attempt debug session, not a guess.
14. NodePort data-plane exposure (kind + chart 10.x)¶
Chart 10.x's bundled envoy-gateway sub-chart defaults the
data-plane Service to type: LoadBalancer, expecting a
provisioner (MetalLB, cloud-provider-kind, …) to allocate an
external IP for each Gateway. kind has no such provisioner —
the data-plane Service never gets a status.loadBalancer
entry, the Gateway condition reports AddressNotUsable, and
listener program-failure cascades into PROGRAMMED=False. The
control-plane Service (named envoy-gateway) is still
ClusterIP and reaches its own pods fine; it's the data-plane
Service (visible after the first Gateway is reconciled, named
like envoy-gitlab-gitlab-gw-<id>) that's wedged.
The fix spans three places. Kind explicitly does not use
extraPortMappings; host access happens through a
bootstrap-managed port-forward, not a host-port mapping.
-
kind side —
infra/tofu/cluster.tfdoes not declareextraPortMappings. The control-plane container is published only on the kind network bridge (default6443/API). Host access to in-cluster Services happens viakubectl port-forward, driven by the bootstrap CLI (blueprint-bootstrap --port-forward <target>orblueprint-secrets port-forward <target>). A top-of-file comment onkind_configdocuments the reasoning. -
chart side —
infra/scripts/bootstrap/phase2/references/helm-values-gitlab.yamloverridesgatewayApiResources.envoy.proxySpec.provider.kubernetes.envoyService.typetoNodePortand pins every listener'snodePortto one of three fixed values:30443for HTTPS,30080for HTTP,30022for SSH. The chart'sgatewayApiResources.gateway.addressesis set to[]— without this, the chart copiesglobal.hosts.externalIP(127.0.0.1 in dev) intoGateway.spec.addresses, the Envoy operator then setsexternalIPs: [127.0.0.1]on the operator-managed Service, and Kubernetes rejects it ("may not be in the loopback range 127.0.0.0/8"). Empty addresses = no externalIPs = no rejection. -
bootstrap side —
phase2/gitlab.py:GitlabInstaller._patch_gateway_data_planeruns afterhelm upgrade --install, finds the operator-builtenvoy-gitlab-gitlab-gw-<id>NodePort Service, and re-pins itsnodePortsto30443/30080/30022. The chart's ownproxySpecvalues file is best-effort: Envoy Gateway still picks random NodePorts in the 30000–32767 range under the default config. The patch is the authoritative wire-up so the bootstrap CLI's port-forward target list is stable across chart upgrades.
The Gateway's listeners keep port: 443 / port: 80 /
port: 22 (service-port values); nodePort is a separate
field, only meaningful when the data-plane Service type is
NodePort. Without explicit nodePort values, Envoy would
pick random ports in the 30000–32767 range and the bootstrap
CLI's port-forward mapping would drift on every chart
upgrade.
Why NodePort + kubectl port-forward instead of MetalLB or
cloud-provider-kind:
- MetalLB (BGP / L2 mode) gives each LoadBalancer Service a routable IP. Heavier: a separate DaemonSet, a config block in the chart, and a new operator to debug when something breaks. We don't need it.
- cloud-provider-kind injects
<NodeIP>:NodePort-style endpoints directly into LoadBalancer Services. Cleaner UX (one less chart override) but newer and less battle-tested than NodePort, and the bootstrap still needskubectl port-forwardfor ports that aren't LoadBalancer Services in the first place (Container Registry on 5000, GitLab workhorse on 8181, MinIO on 9000, etc.). - kind NodePort Service + bootstrap-driven port-forward
is the pattern we ship with. No new control-plane
components; the existing
bootstrap/port_forward.pyprimitive (used byOpenBaoClient,GitlabClient, and the CLI dispatcher) is the access path. The--preserve-datastory and thebootstrap --destroycontract both keep working unchanged because there's no host port mapping to coordinate.
Reaching the GitLab hostnames¶
Once the chart is installed and the data-plane is patched, the dev-side flow is:
/etc/hostsmapsgitlab.local.example.net(and the registry/kas/minio/openbao siblings) to127.0.0.1.- Trust the wildcard CA on the host (Phase 2 cert mint,
exported to
infra/tls/wildcard/ca.pem). - Run
uv run blueprint-bootstrap --port-forward gitlabin the background. The CLI binds127.0.0.1:8443to the Gateway's ClusterIP on the NodePort service, survives until the job is killed, and re-binds on Ctrl-C. (We use8443, not443, because443on the host is reserved for the kind control-plane's API server.) - Browser hits
https://gitlab.local.example.net; the local CA + the local/etc/hostsresolve to127.0.0.1:8443; kube-proxy forwards to the Envoy data-plane onnodePort 30443; Envoy terminates the wildcard TLS and routes by SNI togitlab-web(or the matching listener).
For in-cluster/CLI tasks that don't go through the Gateway
(the Container Registry API on 127.0.0.1:5000, the MinIO
S3 endpoint on 127.0.0.1:9000, the GitLab workhorse HTTP
on 127.0.0.1:8181, etc.), use the CLI dispatcher:
uv run blueprint-secrets port-forward --list # all targets
uv run blueprint-secrets port-forward gitlab-registry # 127.0.0.1:5000
It picks the tofu-written kubeconfig, the right namespace,
the right Service + cluster-port, and tears the forward down
on Ctrl-C. See bootstrap/secrets_cli.py:PortForwardTarget
for the registry list and AGENTS.md § Hard rules for the
"don't reintroduce system-level kubectl port-forward
scripts" rule — the CLI is the supported way.
15. Trade-offs (why it's shaped this way)¶
- Chart-managed TLS instead of cert-manager. Public
Let's Encrypt cannot validate
*.local.example.net(the host has no public DNS). The chart's self-signed path is the simplest thing that works today; the moment public DNS is delegated, we swap to cert-manager with DNS-01 by changing one value (global.gatewayApi.configureCertmanager: true) -
adding an
Issuer. The rest of the bootstrap doesn't change. -
hvac instead of
kubectl exec ... bao. The exec path has bitten us twice: the kubelet round-trips every call (1–2 s/secret), and the pod's token helper doesn't always survive across separatekubectl execinvocations (silent 403s onkv put). hvac is the official Python Vault client (OpenBao is API-compatible) and gives us structured errors. The trade-off: we have to manage a port-forward. The_PortForwardclass inphase2/secrets.pydoes that lazily per process and tears it down on atexit. -
One phase 2 step per chart, no composite steps. The pipeline is a flat list of 13 steps today. The temptation to collapse steps (e.g. "install GitLab + Runner + capture token" into one mega-step) makes failure diagnosis much harder. Keep steps fine-grained: one chart per step, with post-install work in the same
install()method. - Bootstrap-minted wildcard cert, not the chart's cfssl
Job. The chart ships a pre-install
cfsslJob that mints the wildcard cert and writes it to a Secret the Gateway listeners reference. That Job only runs whengitlab.ingress.tls.configuredreturns"false"— i.e. when no external ingress is set up. Withglobal.gatewayApi.enabled=true, the chart'sgitlab.ingress.tls.configuredgetter returns"true"and the cfssl Job is skipped, leaving the Gateway listeners pointing at the wrong Secret name (gitlab-tls, the cert-manager default). We work around this by minting the CA + cert ourselves (phase2/wildcard_certs.py), writing it toinfra/tls/wildcard/, and materialising 4 Secrets (gitlab-wildcard-tls,registry-tls,kas-tls,minio-tls) before the GitLab chart install. The values override inreferences/helm-values-gitlab.yamlflipsglobal.ingress.tls.secretName: gitlab-wildcard-tlsso the Gateway listeners find our Secret. Idempotent: the cert has 10-year validity and the installer skips regen if the cert has ≥30 days left. - Stable PVs survive
tofu destroy, but--destroywipes them. The stable PV/PVC pairs (CloudNativePG, Redis, MinIO, OpenBao, Gitaly) are bound to hostPath dirs underinfra/data/shared/stable/. Aftertofu destroy && tofu apply && bootstrap --phase 2, the new cluster's PVCs re-bind to those dirs. But the on-disk PostgreSQL data was written with the credentials from the previous install's chart-minted Secrets — and the chart mints fresh random passwords on the next install, so PG logsFATAL: password authentication failed for user "gitlab". Thepersistent_secrets.py:restore()step closes that loop by re-applying the previous chart-managed Secrets BEFORE the chart install (so the chart sees them already exist and reuses them). For chart 10.x the externalised services (CloudNativePG/Redis/MinIO) have an additional layer: the bootstrap re-creates their PG roles + databases from a host snapshot (seecnpg-role-passwords.json) when the Cluster was recreated but--destroywasn't run. The inverse case (testing fresh install on top of stale data) is broken by design: that's whatbootstrap --destroyis for. -
*.local.example.netresolves on the host, not in the cluster. The Gateway has 4 listeners, one per hostname (gitlab,registry,kas,minio). Browsers reach Envoy via the host's/etc/hostsmapping. Inside the cluster, pods use Service DNS (gitlab-webservice-default.gitlab.svc:8181). For curl from the host with port-forward, usecurl --resolve <host>:8443:127.0.0.1 https://<host>:8443/...so the SNI matches the listener hostname — without SNI, Envoy returnsfilter_chain_not_foundand the TLS handshake fails (unexpected eof while reading). -
Vendored CRDs, not the upstream tarball. The
gateway-api-crds/generated/directory has 8 chart-shipped Envoy policy CRDs (Backends, BackendTrafficPolicies, ClientTrafficPolicies, EnvoyExtensionPolicies, EnvoyPatchPolicies, EnvoyProxies, HTTPRouteFilters, SecurityPolicies). The 1 upstream standard-channel CRD set (Gateway API v1.5.0) is applied directly from thehttps://github.com/kubernetes-sigs/gateway-api/...URL — it doesn't change between chart upgrades, so vendoring it just bloats the repo. We don't fetch the chart's CRDs from a tarball at install time because that would (a) add a network dependency the rest of the pipeline doesn't have, (b) pull in CRDs the chart doesn't need (TCPRoute, UDPRoute, BackendTLSPolicy, etc. — all experimental), and (c) makekubectl diffnoisy. Re-vendoring when versions bump is a manualcurl -sL <url> | yqagainst the chart'scrds/dir + the chart 10.x nested layout (gitlab/charts/gateway-helm/charts/crds/crds/). -
Idempotency over speed. A few steps could be faster if we skipped the "is this already done?" probe (the openbao init probe is the obvious one). We keep them anyway because the failure mode they prevent — partial state from a previous interrupted run — is much worse than the 1–2 s the probe costs. Re-runs are safe and resumable, which is the property that makes the iteration loop work.