Zum Inhalt

Runbook — Deploy EPPA v2 at eppa2.firemandeveloper.com

This runbook spins up the modernized EPPA (i18n, self-hosted auth, polished marker UX) at a parallel subdomain — without touching the running production at eppa.firemandeveloper.com.

Total estimated effort: ~20 minutes if DNS + cert + nginx land cleanly; ~40 min if certbot needs DNS-01.


0. Pre-flight

# Confirm legacy prod is healthy (we do NOT touch it).
curl -sI https://eppa.firemandeveloper.com | head -2
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep eppa

You should see the eppa container Up, listening on 127.0.0.1:9002 + 127.0.0.1:8100. If not, stop: the issue is unrelated to this rollout and must be triaged before touching v2.


1. DNS

Add an A record for eppa2.firemandeveloper.com pointing at the same public IPv4 as eppa.firemandeveloper.com. Cloudflare credentials are in vault (aranserver:~/.zshrcCLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID).

# Find the prod IP (same target).
PROD_IP=$(dig +short eppa.firemandeveloper.com | head -1)

# Create record via Cloudflare API (requires vault-env or the env vars exported).
curl -fsS -X POST \
  "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records" \
  -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data @- <<EOF
{
  "type": "A",
  "name": "eppa2",
  "content": "${PROD_IP}",
  "ttl": 300,
  "proxied": false
}
EOF

# Verify (may take 60s).
dig +short eppa2.firemandeveloper.com

Set "proxied": true instead if you want Cloudflare's edge to terminate TLS. With false, Nginx on the origin terminates with Let's Encrypt — that's what this runbook assumes (matches the eppa.firemandeveloper.com pattern).


2. Build + start the eppa2 container

cd /home/luis/personal/github-repos/labis-eppa-software/fronts/eppa

# Build the v2 image. NEXT_PUBLIC_API_URL is baked in at build time, hence
# this MUST be done with the right value -- already wired in docker-compose.yml.
docker compose build eppa2

# Start it -- runs in parallel with the legacy eppa container.
docker compose up -d eppa2

# Verify it's listening on the v2 ports (9003 / 8101).
sleep 5
curl -fsS http://127.0.0.1:9003/   | head -5
curl -fsS http://127.0.0.1:8101/health

The Python API health endpoint should respond {"status":"ok",...}. The Next.js root will 307 → /es (i18n routing).


3. TLS cert (Let's Encrypt)

Two paths — pick one:

Option A: New standalone cert (simpler, but one more cert to renew)

sudo certbot certonly --nginx \
  -d eppa2.firemandeveloper.com \
  --non-interactive --agree-tos --email ops@firemandeveloper.com

# This creates /etc/letsencrypt/live/eppa2.firemandeveloper.com/fullchain.pem
# Then edit deploy/nginx/eppa2.firemandeveloper.com.conf to point at it
# (sed-replace lifectl.firemandeveloper.com with eppa2.firemandeveloper.com on
# the ssl_certificate / ssl_certificate_key lines) before copying to /etc/nginx.
sudo certbot certonly --nginx \
  --cert-name lifectl.firemandeveloper.com \
  --expand \
  -d lifectl.firemandeveloper.com \
  -d eppa.firemandeveloper.com \
  -d foundry.firemandeveloper.com \
  -d labis.firemandeveloper.com \
  -d manos.firemandeveloper.com \
  -d openpose.firemandeveloper.com \
  -d eppa2.firemandeveloper.com

# The existing eppa2 nginx conf already points at the lifectl cert, so no
# edits required after expansion.

# Create a fresh htpasswd file just for v2 (decouples from legacy prod creds).
sudo htpasswd -c /etc/nginx/.htpasswd-eppa2 eppa2-preview
# Enter password when prompted. Communicate via the same channel as legacy creds.

If you want v2 wide open (no basic auth), comment out the auth_basic and auth_basic_user_file lines in deploy/nginx/eppa2.firemandeveloper.com.conf.


5. Install + reload Nginx

# Copy the prepared site config (already in the repo).
sudo cp /home/luis/personal/github-repos/labis-eppa-software/fronts/eppa/deploy/nginx/eppa2.firemandeveloper.com.conf \
        /etc/nginx/sites-enabled/eppa2

# Sanity check + reload.
sudo nginx -t && sudo systemctl reload nginx

If nginx -t fails with a cert path error, you skipped step 3 — go back.


6. End-to-end smoke

Preferred scriptable smoke:

cd /home/luis/personal/github-repos/labis-eppa-software/fronts/eppa

EPPA_SMOKE_BASIC_USER=eppa2-preview \
EPPA_SMOKE_BASIC_PASSWORD='<password>' \
bash scripts/smoke-production.sh \
  --base-url https://eppa2.firemandeveloper.com \
  --api-base-url https://eppa2.firemandeveloper.com \
  --locale es

The smoke command verifies the root frontend locale redirect, the resolved locale route, /api/health, and the authenticated-safe /api/auth/me endpoint. It exits non-zero at the first failed check and confirms the API returns X-Request-ID on the auth-safe endpoint.

Manual equivalent:

# 1. Public TLS resolves and serves a 401 (basic auth) — proves Nginx + cert chain.
curl -sI https://eppa2.firemandeveloper.com/ | head -3

# 2. With auth, hits the i18n redirect.
curl -sIL -u eppa2-preview:<password> https://eppa2.firemandeveloper.com/ | grep -iE 'HTTP|Location'

# Expected sequence:
#   HTTP/2 307           ← Next.js next-intl middleware
#   Location: /es        ← default locale
#   HTTP/2 200           ← landed on /es

# 3. API health proxied through:
curl -fsS -u eppa2-preview:<password> https://eppa2.firemandeveloper.com/api/health | jq

# 4. Locale prefix variants all resolve:
for loc in es en de pt-BR; do
  printf "%-8s " "$loc"
  curl -sI -u eppa2-preview:<password> "https://eppa2.firemandeveloper.com/$loc" | head -1
done

# 5. Legacy prod is still intact.
curl -sI https://eppa.firemandeveloper.com/ | head -2
docker ps --format '{{.Names}}\t{{.Status}}' | grep -E '^eppa\b'   # original container still Up

If all 5 steps return as expected, L6 is done.


7. Rollback (in case something is wrong)

# Stop the v2 container.  Legacy prod is unaffected.
docker compose stop eppa2

# Remove the nginx site.
sudo rm /etc/nginx/sites-enabled/eppa2
sudo nginx -t && sudo systemctl reload nginx

If basic-auth, cert, or DNS were misconfigured, removing the site means eppa2.firemandeveloper.com returns a connection error / 404 — but no other subdomains are touched. The image (eppa-eppa2) and the docker volume stay, so a fix-and-restart is fast.


Notes & follow-ups

  • Auth backend wiring (L5): eppa2 is configured to talk to the local Postgres at host.docker.internal:5432. Make sure docker compose up -d postgres minio has been run before launching eppa2, OR start them all together: docker compose up -d postgres minio eppa2. The first time you bring it up, run the Alembic migration:

    docker compose exec eppa2 sh -c 'cd /app && DATABASE_URL=$DATABASE_URL python3 -m alembic upgrade head'
    

  • Bootstrap first admin: after migrations, create the first clinic/admin through the guarded onboarding endpoint. This path works only while there is no active admin yet, so there are no default credentials and no direct DB row edits:

    curl -sS -X POST https://eppa2.firemandeveloper.com/api/auth/bootstrap-first-admin \
      -H 'Content-Type: application/json' \
      -d '{
        "organization_name": "LABIS",
        "organization_slug": "labis",
        "admin_email": "admin@example.com",
        "admin_password": "replace-with-a-strong-unique-password"
      }'
    
    For an additional clinic after an admin already exists, set a temporary EPPA_BOOTSTRAP_SETUP_TOKEN secret in the service environment and send it as setup_token in the same request. The token is single-use; rotate/remove it immediately after the onboarding call succeeds.

  • JWT secret: generate the secret with an operating-system CSPRNG or password manager and keep it outside the repository. Set EPPA_JWT_SECRET_SOURCE to its absolute host path. Compose mounts that file at /run/secrets/eppa2_jwt_secret, which matches JWT_SECRET_FILE. RS256 remains available through the key paths documented in python/auth/config.py.

  • Cookie domain: the next-intl middleware sets cookies on the requesting hostname. Since v2 lives on a different subdomain, cookies are NOT shared with eppa.firemandeveloper.com — users will need to log in once on each host. This is intentional during the parallel rollout.

  • Memory budget: docker-compose limits eppa2 to 768 MB (vs 512 MB for the legacy eppa, because i18n message catalogs + Postgres connection pool + argon2 password hashing all eat a bit). Tune via mem_limit if the host is tight.

  • Disclaimer: the "Research use only — Not a medical device" footer (per compliance/91-non-device-disclaimer.md) is NOT yet rendered in the UI as of this lane. It must be added before any external practitioner uses v2.