The .platform.yml Contract¶
Source of truth
The Jinja2 templates in hns-platform/roles/hns-service/templates/*.j2 (which consume these keys) and the real .platform.yml files in each service repo. Update this page when a template starts consuming a new key.
Every deployable service repo carries a .platform.yml at its root. It declares what the service needs (containers, ports, health checks, scaling, ingress); the platform supplies the infrastructure values (database host, credentials, domains) as Jinja2 variables. A service writes {{ database_url }} and never knows where PostgreSQL lives.
| The service decides | The platform decides |
|---|---|
| Container image, command, ports, health-check paths | Server IPs, network topology, namespace |
| Resource requests/limits, scaling min/max | Connection strings, credential values |
Env variable names and {{ var }} references |
Env variable values (vault / Nebion) |
| Ingress host, post-deploy commands | Image tag, pull policy, TLS, labels |
Top level¶
deployments: # required — list of Deployment + Service (+ HPA/Ingress) stanzas
- name: ...
jobs: # optional — one-shot batch Jobs
- name: ...
YAML anchors (x-…:, e.g. x-backend-env) are ignored by the role — it reads only deployments and jobs. They exist purely to share blocks within the file (docker-compose-style extension fields).
Per-deployment keys¶
| Key | Renders to |
|---|---|
name |
Deployment/Service/Ingress/HPA resource name; pod label app |
build |
{context, dockerfile, args} — image is built from the repo and pushed to the internal registry. Omit for pre-built public images. |
init_containers |
initContainers[] — {name, image, command, volume_mounts} |
containers |
containers[] (required, ≥1) — see below |
scaling |
HPA — {min_replicas, max_replicas, target_cpu} (also sets replicas) |
discovery |
when truthy, also renders a headless Service (for stateful peer discovery) |
ingress |
Ingress + TLS — see TLS & ingress |
post_deploy |
commands run after a successful rollout — [{container, command}] |
The Deployment strategy is chosen automatically: Recreate if any container volume requests a size (PVC-backed, can't have two pods on one RWO volume), otherwise RollingUpdate with maxUnavailable: 0, maxSurge: 1 (zero-downtime). replicas = scaling.min_replicas (default 1).
Per-container keys¶
| Key | Notes |
|---|---|
name |
Container name |
image |
Image URI. The exact string build is substituted with registry/<deployment-name>:<image_tag>. A sidecar reusing another deployment's image must spell out the full {{ registry_endpoint }}/<name>:{{ image_tag }}. |
command / args |
Override ENTRYPOINT / CMD. Use args when the image's ENTRYPOINT already names the binary (e.g. ntfy: args: ["serve"]). |
port or ports |
Single port: 80, or ports: [{port, name?}, …] for multi-port (e.g. NATS). |
env |
Map of NAME: value. Values are rendered with string \| to_json so backslashes/quotes (e.g. a CORS regex) survive. A value of { field_ref: metadata.namespace } becomes a valueFrom.fieldRef. |
config |
[{source, mount, template?}] — file mounted from the repo via ConfigMap. source is a repo-relative path; the file lands at mount (subPath = its basename). template: true renders it as Jinja2 first. |
volumes |
[{name, mount, size?|secret?}] — size ⇒ PVC (<deployment>-<name>), secret ⇒ secret volume, neither ⇒ emptyDir. |
probes |
{startup, readiness, liveness}, each {path, port, period_seconds?, failure_threshold?, initial_delay_seconds?} → HTTP-GET probes. |
resources |
{requests: {memory, cpu}, limits: {memory, cpu?}}. Shorthand {memory, cpu} sets request = limit. Defaults: 256Mi / 100m. |
Probe defaults if omitted: startup period 1s / failure 600; readiness initialDelay 5s / period 5s / failure 3; liveness initialDelay 30s / period 10s / failure 3.
Ingress sub-keys¶
| Key | Effect |
|---|---|
host |
FQDN, usually name.{{ domain }}. Also auto-collected into the shared TLS cert. |
path |
Route prefix (default /). |
port |
Backend service port (default = first container's port). |
websocket |
true adds a Traefik sticky-cookie annotation (stateful WS across replicas). |
basic_auth |
htpasswd string (user:bcrypt-hash) → renders a Traefik basic-auth Secret + Middleware. Empty = open. Guards only the public host; ClusterIP traffic is unaffected. |
internal_paths |
List of path prefixes (e.g. /api/v1/internal) that Traefik serves only in-cluster and returns 403 for from outside. |
Jobs¶
jobs[] entries take {name, image, command, env, config, backoff_limit?, ttl?} (defaults: backoff_limit 3, ttl 86400s). Jobs do not support image: build — point them at a pre-built image.
Worked example¶
A trimmed two-container deployment (nginx + app) with a build, scaling, an internal-only path, and post-deploy migrations:
deployments:
- name: my-service
build:
context: .
dockerfile: Dockerfile
containers:
- name: nginx
image: nginx:1.27-alpine
port: 80
config:
- source: docker/nginx/k8s.conf
mount: /etc/nginx/conf.d/default.conf
probes:
readiness: { path: /stub_status, port: 80 }
- name: app
image: build # → registry/my-service:<sha>-<ns>
port: 9000
command: ["app-server"]
env:
DATABASE_URL: "{{ database_url }}" # value supplied by the platform
K8S_NAMESPACE: { field_ref: metadata.namespace }
resources:
requests: { memory: 512Mi, cpu: 200m }
scaling:
min_replicas: 1
max_replicas: 5
target_cpu: 70
ingress:
host: "api.{{ domain }}"
port: 80
internal_paths:
- /api/v1/internal
post_deploy:
- container: app
command: "app-server migrate --no-interaction"
Last Updated: June 2026