Service Onboarding SOP
How a New Service Joins the Platform
- Create Forgejo issue on the new service repo using
template-issue-feature. MUST include: Dockerfile EXPOSE port, Harbor project name (matching service key), Keycloak realm + client ID (if auth needed). The issue is the spec for the scaffold agent. - Add to var.services in
k3s.tfvars— defines repo, image, port, funnel, and optionally target_revision, source_repo, source_path. This goes through a PR on pal-e-services — create issue, branch, PR, review. No manual git push. - Create kustomize overlay in
pal-e-deployments— createoverlays/{service-name}/prod/withkustomization.yamlanddeployment-patch.yamlfollowing the standard base. This goes through a PR on pal-e-deployments — create issue, branch, PR, review. Setsource_repoandsource_pathin var.services to point ArgoCD at the overlay. See Convention: Kustomize Overlay for Deployments.
⚠️ Secrets warning: Kustomize overlays committed to git must NOT contain real secrets. Akubectl apply -kwill overwrite any manually-created secrets with placeholder values from the overlay. Application secrets (DB passwords, API keys, OAuth tokens) must be created viakubectl create secret genericin the target namespace BEFORE the first kustomize apply or ArgoCD sync. The overlay should reference secrets by name (e.g. inenvFrom) but must not define their data. If the overlay includes a Secret manifest, usestringDataplaceholders and apply secrets manually first so ArgoCD does not clobber them. - Update NetworkPolicy for dependent services — if the new service needs access to MinIO, Postgres, Keycloak, or any other shared platform service, add the new namespace to the relevant NetworkPolicy allowlist in
pal-e-platform/terraform/network-policies.tf. This goes through a PR on pal-e-platform — create issue, branch, PR, review. Without this, the new service getsconnection refusedwhen calling shared services. Check each dependency: MinIO (minionamespace policy), Postgres (postgresnamespace policy), Keycloak (keycloaknamespace policy). Runtofu plan -lock=falseto verify the netpol diff before apply. - Provision databases (if the service needs PostgreSQL) — add an entry to
service_databasesink3s.tfvars. The map key becomes the PostgreSQL role name. Rails 8 apps typically need 4 databases: primary, cache, queue, cable. Example:
This goes through a PR on pal-e-services. After merge, update thewestside_basketball = { password = "generated-password" databases = ["basketball", "basketball_cache", "basketball_queue", "basketball_cable"] }tfvars_contentWoodpecker secret (base64 -w0 ~/secrets/pal-e-services/k3s.tfvars). The terraform creates the PostgreSQL role and databases viadatabases.tf.
Rails apps also need arails-envKubernetes secret with POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, and SECRET_KEY_BASE. Add akubernetes_secret_v1resource inservices.tfreferencing the terraform-managed role and password. The app'sdatabase.ymlproduction config must useENV.fetch/ENV[]to read these values — never hardcode credentials.
⚠️ Database ownership: If databases were created manually before terraform import, table ownership may not match the terraform-managed role. Verify withSELECT tableowner FROM pg_tablesand fix withREASSIGN OWNED BYif needed. - Configure public domain routing (if the service has a public domain like
example.com) — the full chain is:
Each link must be configured:DNS (GoDaddy) → edge-proxy Caddy (178.156.129.142) → Tailscale funnel → k8s Service → Pod - tofu plan/apply — creates ArgoCD Application, namespace, Harbor project, robot accounts, image pull secret, and Image Updater annotations. Always use
-lock=falseto avoid blocking CI. Requires Lucas approval before apply. - Scaffold repo on Forgejo — FastAPI app with Dockerfile,
.woodpecker.yaml, k8s manifests. Dispatch a dev agent with the Forgejo issue from step 1.
CI image registry convention: The.woodpecker.yamlpipeline MUST use the internal Harbor URL (harbor-core.harbor.svc.cluster.local) for image push, NOT the external URL (harbor.tail5b443a.ts.net). The external URL routes through Tailscale DERP relay which is unreliable from inside the cluster and causes intermittent CI failures. AHARBOR_INTERNALenv var may be added to the Woodpecker agent config as the canonical source for this URL. Alldocker pushanddocker tagcommands in the pipeline must target the internal endpoint.
Ruff linter configuration (required for all Python repos): The scaffold MUST include apyproject.tomlwith[tool.ruff]config (line-length = 88,select = ["E", "F", "I", "W"]) and a.pre-commit-config.yamlwith the ruff pre-commit hook (bothrufflint andruff-format). The CI pipeline MUST includeruff check .andruff format --check .as gating steps. See Convention: Python Ruff Standard for the full specification.
Dockerfile CMD (Rails apps): UseCMD ["./bin/rails", "server"]to run Puma directly on port 3000. Do NOT use./bin/thrustunless thethrustergem is in the Gemfile andbin/thrustbinstub exists. The DockerfileEXPOSEport must match the k8s service port.
database.yml (Rails apps): Production config must use env vars (ENV["POSTGRES_PASSWORD"], notENV.fetch(...) { raise }). A hardraisecrashes CI because Rails evaluates all environment configs through ERB even in test mode. UseENV.fetch("VAR", "default")for non-secret values andENV["VAR"]for secrets. - Activate Woodpecker — GAP: no
activate_repotool exists in woodpecker-mcp. Currently requires manual UI activation (Woodpecker UI → Add repository). Tracked:woodpecker-sdk #6. When the MCP tool ships, this step becomes automated. - Add Harbor secrets to Woodpecker — use
mcp__woodpecker__create_repo_secretto addharbor_usernameandharbor_password(from tofu output). Do NOT use the UI — the MCP tool handles this. - Push to trigger pipeline — merge the scaffold PR to main. Woodpecker builds, pushes to Harbor. Use
mcp__woodpecker__list_pipelinesto verify the pipeline succeeds. - ArgoCD syncs — Image Updater picks up the new image, writes
newTagto the overlay kustomization.yaml, ArgoCD deploys. Verify pod is running before marking complete.
Pre-Deploy Validation Checklist
Run this checklist before
tofu apply (between steps 1 and 2). The first five checks come from the mcd-tracker deployment (see deployment-lessons → Service Onboarding — Port + Registry + Realm Validation). Three additional checks (NetworkPolicy, application secrets, CI registry URL) were added 2026-03-24 after a pipeline failure investigation surfaced five deployment failures traceable to SOP gaps. Public domain and database checks added 2026-06-27 after westside-basketball sprint 5 validation. Every item caught here saves a push-wait-debug cycle.| Check | What to verify | Where to find it |
|---|---|---|
| Port consistency | Dockerfile <code>EXPOSE</code> port = var.services <code>port</code> = kustomize <code>containerPort</code> = service <code>targetPort</code> = probe port = ingress backend port | Dockerfile, <code>k3s.tfvars</code>, kustomize overlay |
| Registry path | var.services key (e.g. <code>mcd-tracker-app</code>) matches Harbor project name in <code>image_repo</code> (e.g. <code>mcd-tracker-app/app</code>, NOT <code>mcd-tracker/app</code>). Pipeline pushes to the same path. | <code>k3s.tfvars</code>, <code>.woodpecker.yaml</code> |
| Keycloak realm + client | Exact realm name and client ID specified in Forgejo issue. Agent must NOT guess. Verify realm exists in Keycloak UI before deploy. | Keycloak admin UI, Forgejo issue spec |
| First deploy tag | Pipeline pushes both <code>:latest</code> AND <code>:SHA</code> tags. Kustomize overlay starts with <code>newTag: latest</code>. Image Updater switches to SHA after first sync. | <code>.woodpecker.yaml</code>, kustomize overlay |
| Full apply required | Port changes require full <code>tofu apply</code> (not <code>-target</code>). Targeted apply skips ingress recreation. | Always use full apply for new services |
| NetworkPolicy allowlist | If the service depends on MinIO, Postgres, or Keycloak, verify the new namespace is listed in the corresponding NetworkPolicy in <code>network-policies.tf</code>. Missing entry = <code>connection refused</code> at runtime. Must be merged via PR on pal-e-platform <strong>before</strong> first deploy. | <code>pal-e-platform/terraform/network-policies.tf</code> |
| Application secrets | Application secrets (DB passwords, API keys, OAuth tokens) exist in the target namespace via <code>kubectl get secrets -n {namespace}</code>. Kustomize overlay must NOT define secret data — only reference by name. Secrets must be created via <code>kubectl create secret generic</code> <strong>before</strong> first kustomize apply or ArgoCD sync, or ArgoCD will overwrite them with placeholders. | <code>kubectl get secrets -n {namespace}</code>, kustomize overlay |
| CI registry URL | <code>.woodpecker.yaml</code> uses internal Harbor URL (<code>harbor-core.harbor.svc.cluster.local</code>) for image push, NOT external URL (<code>harbor.tail5b443a.ts.net</code>). External URL routes through Tailscale DERP and is unreliable from inside the cluster. | <code>.woodpecker.yaml</code>, Woodpecker agent config (<code>HARBOR_INTERNAL</code> env var) |
| Ruff linter config | <code>pyproject.toml</code> contains <code>[tool.ruff]</code> with <code>line-length = 88</code> and <code>select = ["E", "F", "I", "W"]</code>. <code>.pre-commit-config.yaml</code> contains ruff hook with both <code>ruff</code> (lint) and <code>ruff-format</code> entries. CI pipeline includes <code>ruff check .</code> and <code>ruff format --check .</code> gates. See <a href="/notes/convention-python-ruff-standard">Convention: Python Ruff Standard</a>. | <code>pyproject.toml</code>, <code>.pre-commit-config.yaml</code>, <code>.woodpecker.yaml</code> |
| Public domain routing | If the service has a public domain: DNS A record → edge-proxy IP. Caddy site block in Salt pillar. Tailscale funnel ingress exists (either via <code>funnel = true</code> in var.services or in kustomize overlay). Funnel hostname matches what Caddy proxies to. <code>curl</code> returns 200 or 302, not 502. | GoDaddy DNS, <code>ssh root@edge-proxy cat /etc/caddy/Caddyfile</code>, <code>kubectl get ingress -n {namespace}</code> |
| Database ownership | If using <code>service_databases</code>: PostgreSQL role name matches terraform map key. All databases owned by that role (<code>SELECT datdba FROM pg_database</code>). All tables owned by that role (<code>SELECT tableowner FROM pg_tables</code>). No orphaned roles from manual provisioning. | <code>psql</code> via port-forward, <code>k3s.tfvars</code> |
| Dockerfile CMD | Rails apps: CMD uses <code>./bin/rails server</code> (not <code>./bin/thrust</code>) unless thruster gem is installed. EXPOSE port matches k8s service port. database.yml production config uses <code>ENV[]</code> for secrets (not <code>raise</code> — breaks CI). | Dockerfile, Gemfile, <code>config/database.yml</code> |
| CI step dependencies | <code>update-kustomize-tag</code> step must depend on <code>build-and-push</code> success. If build is skipped (test failure), tag update must also be skipped — otherwise ArgoCD deploys a non-existent image tag (ImagePullBackOff). | <code>.woodpecker/ci.yaml</code> |
Issue template requirement: Every service onboarding Forgejo issue MUST include these three values explicitly: (1) Dockerfile
EXPOSE port, (2) Harbor project name (matching the service key), (3) Keycloak realm name and client ID (if auth is needed). Agents that create onboarding issues without these fields are violating SOP.Reference
Full procedure documented in
SERVICE_ONBOARDING.md in the pal-e-services repo.Kustomize overlay pattern documented in Convention: Kustomize Overlay for Deployments.
Python linter standard documented in Convention: Python Ruff Standard. Covers pyproject.toml ruff config, .pre-commit-config.yaml hook, and CI gate requirements.
var.services Fields
Each service entry in
var.services is keyed by service name (which becomes the namespace). Fields:forgejo_repo(string) — e.g.,"forgejo_admin/platform-validation"image_repo(string) — e.g.,"platform-validation/validator"port(number) — container portfunnel(bool) — whether to create a Tailscale funnel ingress. Setfalsewhen the kustomize overlay manages its own ingress with a custom hostname (e.g., public domains that don't match the service key)target_revision(string, optional) — git branch ArgoCD watches, defaults to"main"source_repo(string, optional) — Forgejo repo for kustomize overlays, e.g.,"forgejo_admin/pal-e-deployments". When set, terraform addswrite-back-target: kustomizationannotation and points ArgoCD at this repo instead of the service repo.source_path(string, optional) — path within source_repo to the overlay, e.g.,"overlays/pal-e-docs/prod". Defaults to"k8s"if omitted.
The map key itself serves as the service name, namespace, and domain prefix. There is no separate
namespace or domain field.