---
name: prjaas-webapp-onboarding
description: Use when building, containerizing, or deploying a web application onto a PRJAAS-style platform (a Traefik reverse-proxy host where apps are declared as one YAML file per app, images come from a private registry, and Ansible renders the compose + Traefik labels). Self-contained; needs no access to the platform's own source. Covers the tech-stack-agnostic container contract, the app declaration format, per-stack Dockerfiles, the registry build/push flow, an offline (docker save/load) deploy workaround, and deploy + verify steps.
---

# PRJAAS web app onboarding (portable)

How to take a web application written in **any** language/framework, package it
as a container, and deploy it onto a **PRJAAS-style platform**: a single host
running Docker where a Traefik reverse proxy terminates TLS and path-routes to
each app, images are pulled from a private container registry, and an Ansible
role turns a one-file-per-app declaration into a running, TLS-secured service.

You do **not** need access to the platform's own source to use this skill.
Everything you need — the container contract, the declaration format, ready
Dockerfiles, a build/push script, and the deploy steps — is inlined below.

---

## Two actors: who does what

Onboarding is a hand-off between two roles. Keep them distinct — most confusion
comes from blurring them.

| | **App developer** | **Platform manager** |
|---|---|---|
| Owns | The application + its own source repo | The platform IaC repo + the running server |
| Does | Builds the app to meet the **container contract**, writes the Dockerfile, **builds and publishes the image to the registry (Nexus)** | Operates Traefik/TLS/networks/datastores/backups; **reviews the declaration, stores secrets in the vault, runs the deploy** |
| Delivers to the other | Image in the registry + the declaration inputs (image ref, `url_path`, port, env, which secrets/services) + secret **values** (out-of-band) | A live, TLS-secured URL + the app listed on the homepage |
| Never | Touches the platform repo, the vault, or the server | Writes the app's code or its Dockerfile |

**The hand-off in one line:** the *developer* publishes an image and hands over
the declaration inputs + secret values; the *platform manager* commits
`apps/<APP>.yml`, puts the secrets in the vault, and deploys.

Each step below is tagged **[Dev]** (app developer) or **[Platform]** (platform
manager) so it is clear who acts. If you are one person doing both, you simply
switch hats — but the artifacts still cross the same boundary.

---

## 0. Platform coordinates (fill these in)

These values are provided by whoever operates the target platform. Substitute
them wherever they appear below.

| Placeholder | Meaning | Example |
|---|---|---|
| `<REGISTRY>` | Private Docker registry `host:port` | `myregistry.example.com:5000` |
| `<FQDN>` | Public domain the server answers on | `apps.example.com` |
| `<SSH>` | SSH login for the host (sudo) | `admin@apps.example.com` |
| `<APP>` | Your app's short id (`[a-z0-9]`) | `orders` |
| `<PORT>` | Port your app listens on in-container | `8080` |

If the operator uses Ansible Vault for secrets, they also control variables
named `vault_<APP>_<KEY>` (per-app secrets) and `vault_app_db_passwords[<APP>]`
(the app's Postgres password). You reference the *keys*; they hold the values.

---

## 1. The container contract (tech-stack agnostic) — [Dev]

The platform does not care what runs inside the container as long as the image:

1. **Serves plain HTTP on one TCP port.** No TLS inside the container — the
   proxy terminates TLS and forwards plain HTTP. Pick `<PORT>` (e.g. `8080`).
2. **Binds to all interfaces** (`0.0.0.0` / `[::]`), never `127.0.0.1`, so the
   proxy on the shared Docker network can reach it.
3. **Works under a URL sub-path.** Apps are routed at
   `Host(<FQDN>) && PathPrefix(/<APP>)`. With prefix-stripping enabled
   (recommended) the proxy removes `/<APP>` before forwarding, so the app is
   hit at `/`. To keep generated links/assets/redirects correct, read a
   **path-base env var** (`PATH_BASE` by convention) and prefix your own URLs
   with it. Framework knobs:
   - ASP.NET Core: `app.UsePathBase(Environment.GetEnvironmentVariable("PATH_BASE"))`
   - FastAPI: `FastAPI(root_path=os.getenv("PATH_BASE", ""))`
   - Express: `app.use(process.env.PATH_BASE || "/", router)`
   - Flask: put the app behind `DispatcherMiddleware` / set `APPLICATION_ROOT`
   - Static/nginx: set a matching `<base href>` or serve from a sub-dir
   Apps that only ever use relative URLs can skip this, but most frameworks
   need it for asset URLs, `/docs`, OAuth redirects, OpenAPI, etc.
4. **Exposes an HTTP health endpoint** returning `200` (e.g. `/healthz`
   returning `healthy`).
5. **Ships `curl` OR `wget`.** The platform's generated healthcheck runs, inside
   the container, roughly:
   `curl -fsS http://localhost:<PORT>/healthz || wget -q -O /dev/null http://localhost:<PORT>/healthz`.
   Slim base images often have neither — install one
   (`apt-get install -y --no-install-recommends curl`), or omit the healthcheck.
6. **Is `linux/amd64` and current-Docker compatible.** Modern Docker Engines
   reject images with very old manifest schemas (Docker API < 1.40). Build with
   an up-to-date Docker/buildx; when building on arm64, pass
   `--platform linux/amd64`.
7. **Is stateless, or persists only via declared state** — the container
   filesystem is ephemeral. Persist through the platform's shared services
   (Postgres/Redis/object storage) or declared host bind mounts.

Nothing else is imposed: language, framework, and internal design are yours.

---

## 2. The app declaration: one YAML file per app — [Dev drafts, Platform owns]

You deliver one declaration to the operator (or drop it into the platform's
`apps/` directory if you have access). Fields:

| Field | Req | Type | Meaning |
|---|---|---|---|
| `name` | yes | string | App id. Drives the compose project, proxy router/service/middleware names, on-host dir, DB/user name, and secret prefixes. Keep it `[a-z0-9]`. |
| `image` | yes | string | Full image ref **including `<REGISTRY>` host:port**, e.g. `<REGISTRY>/<repo>/<APP>:<tag>`. Public images may be bare (`nginxdemos/hello:latest`). |
| `url_path` | yes | string | Public path prefix, e.g. `/<APP>`. Routed as `Host(<FQDN>) && PathPrefix(<url_path>)`. |
| `container_port` | yes | int | Port the app listens on inside the container (`<PORT>`). |
| `description` | yes | string | Shown on the platform's app-directory homepage. |
| `strip_prefix` | no (def `false`) | bool | `true` adds a prefix-stripping middleware; pair it with a `PATH_BASE`-aware app. |
| `env` | no | map | Non-secret environment variables. |
| `secret_keys` | no | list | Each key `K` is resolved from the operator's vault var `vault_<name>_<K>` and injected as `K=<value>`. |
| `healthcheck` | no | string | Health path (relative to root). Empty/omitted → no container healthcheck. |
| `shared_services` | no | list | Any of `postgres`, `redis`, `minio`. Joins the internal data network + auto-provisioning/env (see below). |
| `volume_mounts` | no | list | Host bind mounts `"hostpath:containerpath[:ro]"`. |
| `pull_policy` | no (def `always`) | string | `always` \| `missing` \| `never`. Use `never` for a side-loaded image on a host that can't reach the registry (see §7). |
| `platform_network` | no (def `false`) | bool | Join the internal data network to reach the datastores **without** per-app DB provisioning. Use it when the app brings its **own** `DATABASE_URL` (e.g. a shared read-only reporting role) instead of a per-app DB. |

### Auto-wiring from `shared_services`
Behavior on platforms that offer shared datastores (skip if yours doesn't):
- Non-empty list → the container also joins the internal data network.
- `postgres` → a DB + user named `<name>` is provisioned; the app receives
  `DATABASE_URL=postgresql://<name>:<pw>@<postgres-host>:5432/<name>` — **unless**
  the app already declares `DATABASE_URL` (via `env`/`secret_keys`), in which case
  the auto value is skipped and your own is used.
- `redis` → `REDIS_URL=redis://<redis-host>:6379` (same skip-if-declared rule).
- `minio` (S3) → network join only; supply credentials via `env`/`secret_keys`.
- Bringing your own connection string? Set `platform_network: true` and provide
  `DATABASE_URL` yourself — you join the data network but no throwaway DB is created.

### Document your database schema — [Dev]

**Always create your tables with PostgreSQL's built-in documentation features.**
Add a `COMMENT ON` for the database, every table, and every column as part of
your schema/migrations. This is a hard requirement on this platform, not a
nicety: a shared, cluster-wide **read-only reporting role** and an **AI database
chatbot** browse every app's schema, and both rely on these comments to explain
what the data means. Undocumented tables show up as opaque and unusable to them.

```sql
COMMENT ON DATABASE <name> IS 'What this app''s data is for (one sentence).';

COMMENT ON TABLE  orders            IS 'Customer orders (header). Line items live in order_items.';
COMMENT ON COLUMN orders.order_id   IS 'Surrogate primary key.';
COMMENT ON COLUMN orders.status     IS 'Fulfilment status: pending, paid, shipped, cancelled.';
COMMENT ON COLUMN orders.total_eur  IS 'Order total in EUR (>= 0).';
-- ...one COMMENT ON COLUMN per column, every table.
```

Guidelines:
- Cover **every** table and **every** column; note units, allowed/enum values,
  and foreign-key relationships (e.g. `FK -> customers.customer_id`).
- `COMMENT ON` is **idempotent** (it overwrites in place), so it is safe to run
  on every migration/startup — keep the comments in the same file as the DDL.
- Escape single quotes by doubling them (`''`) inside comment strings.
- Verify with `\dd`, `\d+ <table>`, `obj_description('t'::regclass)` and
  `col_description('t'::regclass, <attnum>)`.

This applies whether the platform auto-provisions your DB (`shared_services:
[postgres]`) or you bring your own `DATABASE_URL` — document whatever schema
your app owns.

### Example — registry image, sub-path, no datastore
```yaml
name: <APP>
image: <REGISTRY>/<repo>/<APP>:1.0.0
url_path: /<APP>
description: "What this app does (shown on the homepage)."
container_port: 8080
strip_prefix: true
env:
  PATH_BASE: "/<APP>"
secret_keys: []
healthcheck: /healthz
shared_services: []
volume_mounts: []
pull_policy: always
```

### Example — Postgres-backed app with a secret
```yaml
name: orders
image: <REGISTRY>/team/orders:2.3.1
url_path: /orders
description: "Order service."
container_port: 8080
strip_prefix: true
env:
  PATH_BASE: "/orders"
secret_keys: [API_TOKEN]      # operator sets vault_orders_API_TOKEN; injected as API_TOKEN=...
shared_services: [postgres]   # DATABASE_URL injected; DB+user auto-provisioned
healthcheck: /healthz
```
Ask the operator to add the secrets first: the vault var `vault_orders_API_TOKEN`
and, for Postgres, the app's entry under `vault_app_db_passwords` (key `orders`).

---

## 3. What the platform generates for you (so you don't) — [Platform]

From the declaration, the platform renders and applies a Docker Compose service
with: your `image`, `restart: unless-stopped`, an optional `.env` file, the
shared proxy network (plus the data network if `shared_services`), an optional
container healthcheck (curl→wget), and proxy labels equivalent to:

```
traefik.enable=true
traefik.http.routers.<APP>.rule=Host(`<FQDN>`) && PathPrefix(`<url_path>`)
traefik.http.routers.<APP>.entrypoints=websecure
traefik.http.routers.<APP>.tls=true
traefik.http.routers.<APP>.tls.certresolver=le
traefik.http.services.<APP>.loadbalancer.server.port=<container_port>
# if strip_prefix:
traefik.http.routers.<APP>.middlewares=<APP>-strip
traefik.http.middlewares.<APP>-strip.stripprefix.prefixes=<url_path>
```

TLS certificates are obtained automatically (e.g. Let's Encrypt). A branded
app-directory homepage typically lists your app from its `description` +
`url_path`. **Do not** publish ports, add TLS, or hand-write proxy/compose
config in your image or declaration — the platform owns all of that.

---

## 4. Dockerfiles by stack (copy-paste, contract-compliant) — [Dev]

Each ensures: listens on `8080`, binds `0.0.0.0`, ships `curl`, builds
`linux/amd64`. Add a `/healthz` route in your app returning 200.

### Static site (nginx)
```dockerfile
FROM nginx:alpine
# curl for the healthcheck (alpine nginx has wget already, but curl is explicit)
RUN apk add --no-cache curl
COPY site/ /usr/share/nginx/html/
# Listen on 8080; if served under a sub-path, publish assets under that dir
# or set <base href="/<APP>/"> in your HTML.
RUN sed -i 's/listen       80;/listen       8080;/' /etc/nginx/conf.d/default.conf
EXPOSE 8080
# Add /healthz:
RUN printf 'server{listen 8081;location /healthz{return 200 "healthy";}}' \
    > /etc/nginx/conf.d/health.conf
```

### Python (FastAPI + uvicorn)
```dockerfile
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt ./          # fastapi, uvicorn[standard]
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py ./
EXPOSE 8080
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"]
```
```python
# app.py — reads PATH_BASE so /docs & links work under the sub-path
import os
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
app = FastAPI(root_path=os.getenv("PATH_BASE", ""))
@app.get("/healthz", response_class=PlainTextResponse)
def healthz(): return "healthy"
@app.get("/")
def index(): return {"app": os.getenv("APP_NAME", "app"), "ok": True}
```

### Node (Express)
```dockerfile
FROM node:20-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV PORT=8080
EXPOSE 8080
CMD ["node", "server.js"]
```
```js
// server.js
const express = require("express");
const app = express();
const base = process.env.PATH_BASE || "/";
const r = express.Router();
r.get("/healthz", (_q, s) => s.type("text").send("healthy"));
r.get("/", (_q, s) => s.json({ ok: true }));
app.use(base, r);
app.listen(8080, "0.0.0.0");
```

### .NET (ASP.NET Core, multi-stage)
```dockerfile
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore
COPY . ./
RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:8.0
RUN apt-get update && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
COPY --from=build /app/publish ./
ENTRYPOINT ["dotnet", "YourApp.dll"]
```
```csharp
// Program.cs
var app = WebApplication.CreateBuilder(args).Build();
var pb = Environment.GetEnvironmentVariable("PATH_BASE");
if (!string.IsNullOrWhiteSpace(pb) && pb != "/") app.UsePathBase(pb);
app.MapGet("/healthz", () => Results.Text("healthy"));
app.MapGet("/", () => Results.Json(new { ok = true }));
app.Run();
```

### Java (Spring Boot, multi-stage Maven)
```dockerfile
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml ./
RUN mvn -q -B -DskipTests dependency:go-offline
COPY src ./src
RUN mvn -q -B -DskipTests package

FROM eclipse-temurin:21-jre
RUN apt-get update && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
EXPOSE 8080
COPY --from=build /src/target/app.jar ./app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
```
```yaml
# application.yml — trust the proxy's forwarded headers so URLs (and any OAuth
# redirect_uri) are built with the external scheme/host/prefix, not the internal
# request. This is the Spring equivalent of PATH_BASE handling.
server:
  port: 8080
  forward-headers-strategy: framework   # honors X-Forwarded-Proto/Host/Prefix
```
```java
// Health endpoint (public). With forward-headers, request.getContextPath()
// returns the stripped prefix ("/<APP>") — build asset/link URLs from it so
// they work whether the visitor hits /<APP> or /<APP>/.
@GetMapping(value = "/healthz", produces = "text/plain")
public String healthz() { return "healthy"; }
```

Verify locally before shipping:
```bash
docker build -t <APP>:dev .
docker run --rm -p 8080:8080 -e PATH_BASE=/ <APP>:dev
curl -fsS http://localhost:8080/healthz   # -> healthy
```

---

## 5. Portable build-and-push script — [Dev]

Drop this next to your Dockerfile as `build-and-push.sh`. It builds, always
exports a portable tarball to `./target/` (handy for the offline path in §7),
then pushes unless `SKIP_PUSH=1`.

```bash
#!/usr/bin/env bash
set -euo pipefail
REGISTRY="${REGISTRY:-<REGISTRY>}"          # e.g. myregistry.example.com:5000
REPO="${REPO:-<repo>/<APP>}"                # e.g. team/orders
TAG="${TAG:-1.0.0}"
IMAGE="${REGISTRY}/${REPO}:${TAG}"
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

echo ">> Building ${IMAGE}"
docker build --platform linux/amd64 -t "${IMAGE}" "${here}"

mkdir -p "${here}/target"
tar="${here}/target/${REPO//\//_}-${TAG}.tar"
echo ">> Exporting ${tar}"
docker save -o "${tar}" "${IMAGE}"

[[ "${SKIP_PUSH:-0}" == "1" ]] && { echo ">> SKIP_PUSH=1"; exit 0; }

if [[ -n "${REGISTRY_USER:-}" && -n "${REGISTRY_PASSWORD:-}" ]]; then
  echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin
fi
echo ">> Pushing ${IMAGE}"
docker push "${IMAGE}"
echo ">> image: ${IMAGE}"
```
```bash
chmod +x build-and-push.sh
TAG=1.0.0 ./build-and-push.sh          # build + push
TAG=1.0.0 SKIP_PUSH=1 ./build-and-push.sh   # build + tarball only (offline)
```

Use **immutable version tags** (`1.0.1`), not bare `latest`, for auditable
rollouts.

### Registry with an untrusted/self-signed TLS cert
If `docker push` fails with `x509: certificate signed by unknown authority`,
either trust the CA
(`/etc/docker/certs.d/<REGISTRY>/ca.crt`, then restart Docker) or add
`"insecure-registries": ["<REGISTRY>"]` to the workstation's
`/etc/docker/daemon.json` and restart Docker.

---

## 6. Normal deploy (registry reachable from the host) — [hand-off → Platform]

1. **[Dev]** Push the image (§5) and set `image:` to that ref in the declaration
   inputs.
2. **[Dev → Platform]** Hand the platform manager the declaration inputs and the
   secret **values**. The platform manager commits `apps/<APP>.yml` and adds the
   secrets to the vault. (If you are also the platform manager, do it yourself.)
3. **[Platform]** Run the platform's app-deploy step (an Ansible run). This
   renders the compose + proxy labels, pulls per `pull_policy`, starts the
   container, and refreshes the homepage.

---

## 7. Offline / firewall-blocked deploy (side-load workaround) — [Dev builds, Platform loads & deploys]

When the **host cannot reach the registry** (e.g. a firewall isn't open yet),
transfer the image directly and tell the platform not to pull:

```bash
# 1. Build + export locally (writes target/<repo>_<APP>-<tag>.tar)
TAG=1.0.0 SKIP_PUSH=1 ./build-and-push.sh

# 2. Copy the tarball to the host
scp target/<repo>_<APP>-1.0.0.tar <SSH>:/tmp/

# 3. Load it into the host's Docker (tag is preserved; matches the declaration)
ssh <SSH> 'sudo docker load -i /tmp/<repo>_<APP>-1.0.0.tar && rm /tmp/<repo>_<APP>-1.0.0.tar'

# 4. In the declaration set:
#      pull_policy: never
#    so compose uses the loaded image and never contacts the registry.

# 5. Ask the operator to run the app-deploy step.
```

`docker save`/`load` preserves the exact `host:port/repo:tag`, so the loaded
image satisfies the compose `image:` line with no retag. Remove
`pull_policy: never` (or set `always`) once the registry is reachable.

---

## 8. Verify — [both]

```bash
BASE=https://<FQDN>/<APP>
curl -sS  $BASE/healthz            # -> healthy
curl -sSI $BASE/                   # 200, your content
# container health on the host:
ssh <SSH> 'sudo docker ps --format "{{.Names}}\t{{.Status}}" | grep <APP>'
```

New containers show `health: starting` briefly — wait for `healthy` before
concluding failure. A first request returning a proxy/nginx `404` usually means
the container is still booting and the proxy is briefly falling through to the
homepage catch-all; retry after startup.

---

## 8b. Authentication with Microsoft Entra ID (per-app OIDC) — [Dev app code, Platform vault]

If apps must authenticate users with Entra ID, the convention here is **one
Entra app registration per app** (not a shared edge proxy). Entra still gives
users single sign-on across all apps, so they are not prompted again after the
first login; each app just gets its own client, audience and tokens.

**Declaration convention** (reuses the standard `env` + `secret_keys`):
```yaml
env:
  ENTRA_TENANT_ID: "<directory-tenant-guid>"   # not secret
  ENTRA_CLIENT_ID: "<application-client-id>"    # not secret
secret_keys: [ENTRA_CLIENT_SECRET]              # -> vault_<APP>_ENTRA_CLIENT_SECRET
```
Only the client secret is secret (goes in the operator's vault). Tenant/client
IDs live in the declaration.

**Registration in Entra** (per app):
- Platform **Web**; register the exact callback URL your framework produces
  under the app's sub-path — with prefix-stripping this is
  `https://<FQDN>/<APP>/<callback>` (e.g. `.../<APP>/signin-oidc` for ASP.NET,
  or a relocated `.../<APP>/oauth2/code/<id>` for Spring). It must match byte
  for byte or Entra returns `AADSTS50011`.
- Add a post-logout / front-channel logout URL (typically `https://<FQDN>/<APP>`).
- Delegated Graph scopes `openid profile email`; **grant admin consent** to
  avoid per-user consent prompts.
- Use Authorization Code flow with a confidential client (the secret) — no
  implicit/hybrid token issuance needed.

**Sub-path + reverse proxy is the tricky part.** Because the proxy strips
`/<APP>` and terminates TLS, your app must build redirect URIs from the
**forwarded** scheme/host/prefix, not the internal request, or the `redirect_uri`
sent to Entra will be wrong (`http://` or missing `/<APP>`). Framework knobs:
- **Spring Boot:** `server.forward-headers-strategy=framework` (honors
  `X-Forwarded-Proto/Host/Prefix`); consider relocating Spring Security's
  default `/login/oauth2/...` endpoints if your prefix is itself `/login`.
- **ASP.NET Core:** `UseForwardedHeaders` (ForwardedHeaders =
  XForwardedProto|XForwardedHost) + `UsePathBase(PATH_BASE)`.
- **Python (Authlib/MSAL + FastAPI/Flask):** trust `X-Forwarded-*`
  (`ProxyHeadersMiddleware`/`ProxyFix`) and set `root_path`/base so the
  callback URL is absolute and prefixed.

**Verify** the exact production redirect URI before registering: hit your
`authorize`-start endpoint locally with simulated proxy headers and read the
`redirect_uri` query parameter it sends to Entra:
```bash
curl -s -o /dev/null -w '%{redirect_url}\n' \
  -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: <FQDN>' \
  -H 'X-Forwarded-Prefix: /<APP>' \
  http://localhost:8080/<your-authorize-start-path>
```

**Caveats:**
- This is **authentication** (who is the user). **Authorization** (roles,
  group checks) still lives in each app; forward Entra **App Roles** or assigned
  groups as claims, and beware the group-overage limit (>~200 groups → Graph
  link instead of inline claims — prefer App Roles).
- Keep the app off any public port (it already is — proxy-only) so identity
  can't be forged; validate ID-token audience = your `ENTRA_CLIENT_ID`.
- **Startup vs. discovery tradeoff.** If the app is configured with the OIDC
  `issuer-uri`, the framework fetches the discovery document **at startup** —
  clean, but bad/placeholder config or an unreachable Entra will **crash-loop**
  the container. Configuring the **explicit endpoints** instead (authorization/
  token/jwks/userinfo) skips boot-time discovery: the app starts regardless and
  only contacts Entra when a user logs in — handy for shipping a public entry
  screen with a "Sign in" button before real credentials exist. Choose per app.

### Worked example — Java / Spring Boot login served at `/login`

This is the trickiest common case: a Spring Security OIDC app whose URL prefix
(`/login`) **collides** with Spring's own default OAuth2 endpoints. The four
things that make it work:

1. **Trust forwarded headers** so `{baseUrl}` = `https://<FQDN>/login`:
   ```yaml
   # application.yml
   server:
     forward-headers-strategy: framework
   ```
2. **Relocate the OAuth2 endpoints off `/login/oauth2/...`** (otherwise the
   external callback doubles up as `/login/login/oauth2/code/...`):
   ```java
   http.oauth2Login(o -> o
       .authorizationEndpoint(a -> a.baseUri("/oauth2/authorization"))
       .redirectionEndpoint(r -> r.baseUri("/oauth2/code/*")));
   // Register redirect URI:  https://<FQDN>/login/oauth2/code/azure
   ```
   ```yaml
   spring.security.oauth2.client.registration.azure.redirect-uri: "{baseUrl}/oauth2/code/{registrationId}"
   ```
3. **Use explicit Entra endpoints, not `issuer-uri`**, so the container starts
   even with placeholder/missing creds (no boot-time discovery, no crash-loop):
   ```yaml
   spring.security.oauth2.client.provider.azure:
     authorization-uri: https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/authorize
     token-uri:         https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token
     jwk-set-uri:       https://login.microsoftonline.com/${ENTRA_TENANT_ID}/discovery/v2.0/keys
     user-info-uri:     https://graph.microsoft.com/oidc/userinfo
     user-name-attribute: name
   ```
4. **Build asset/link URLs from the forwarded prefix** so they are correct with
   or without a trailing slash (a relative `assets/x.png` on `/login` — no
   slash — resolves to the site root and 404s):
   ```java
   String cp = request.getContextPath();      // "" locally, "/login" behind Traefik
   // emit href="{cp}/assets/inetum-logo.png", href="{cp}/oauth2/authorization/azure", ...
   ```

Declaration for it (note `strip_prefix: true`; the app handles the prefix via
forwarded headers, so no `PATH_BASE` env is needed for Spring):
```yaml
name: myloginapp
image: <REGISTRY>/team/myloginapp:1.0.0
url_path: /login
container_port: 8080
strip_prefix: true
env:
  ENTRA_TENANT_ID: "<tenant-guid>"
  ENTRA_CLIENT_ID: "<client-id>"
secret_keys: [ENTRA_CLIENT_SECRET]     # -> vault_myloginapp_ENTRA_CLIENT_SECRET
healthcheck: /healthz
```
Confirm the exact production `redirect_uri` before registering it in Entra:
```bash
curl -s -o /dev/null -w '%{redirect_url}\n' \
  -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: <FQDN>' -H 'X-Forwarded-Prefix: /login' \
  http://localhost:8080/oauth2/authorization/azure
# -> https://<FQDN>/login/oauth2/code/azure   (register exactly this)
```

---

## 9. New-app checklist — [who does what]

**App developer**
1. Write the app; honor the §1 contract (HTTP on `<PORT>`, bind `0.0.0.0`,
   `PATH_BASE`, `/healthz`, `curl`/`wget` present, `linux/amd64`).
2. Write a `Dockerfile` (§4) and `build-and-push.sh` (§5); build + run locally.
   If the app uses a database, **document its schema with `COMMENT ON`** (every
   table + column) in your migrations (§2).
3. **Publish the image to the registry (Nexus)** (§6) — or produce a tarball to
   side-load while the registry is blocked (§7).
4. Draft the declaration inputs (§2) and hand them to the platform manager:
   image ref, `url_path`, `container_port`, `env`, which `secret_keys` /
   `shared_services`, health path. Deliver secret **values** out-of-band.

**Platform manager**
5. Commit `apps/<APP>.yml` in the platform repo and put the secrets in the vault
   (`vault_<APP>_<KEY>`, and `vault_app_db_passwords[<APP>]` for Postgres).
6. If side-loading: `docker load` the image on the host and set
   `pull_policy: never` (§7).
7. Deploy (`--tags apps`) and verify (§8); confirm the homepage lists the app.

*(One person doing both just switches hats — the image + declaration + secrets
still cross the boundary.)*

---

## 10. Common pitfalls

- **Bound to `127.0.0.1`** → the proxy can't reach it; bind `0.0.0.0`.
- **`container_port` ≠ the port the app listens on** → 502 from the proxy.
- **Sub-path assets 404 / broken links** → `strip_prefix: true` without a
  `PATH_BASE`-aware app (or vice-versa). Keep the two in sync.
- **Healthcheck flapping `unhealthy`** → no `curl`/`wget` in the image, or wrong
  `healthcheck` path. Install the tool, fix the path, or omit `healthcheck`.
- **Pull denied / times out on deploy** → registry unreachable and `pull_policy`
  not `never`; side-load and set `never` (§7). If it's auth, the operator must
  log the host into the registry.
- **Image won't run on a modern Docker Engine** → too-old base/manifest; rebuild
  with current tooling, target `linux/amd64`.
- **Secret missing at deploy** → the vault key must be exactly
  `vault_<APP>_<KEY>`; for Postgres also `vault_app_db_passwords[<APP>]`.
