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:~/.zshrc → CLOUDFLARE_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.
Option B: Extend the existing lifectl SAN cert (recommended — one cert to rotate)¶
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.
4. Basic auth user (optional but recommended for v2 preview)¶
# 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):
eppa2is configured to talk to the local Postgres athost.docker.internal:5432. Make suredocker compose up -d postgres miniohas 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:
For an additional clinic after an admin already exists, set a temporarycurl -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" }'EPPA_BOOTSTRAP_SETUP_TOKENsecret in the service environment and send it assetup_tokenin 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_SOURCEto its absolute host path. Compose mounts that file at/run/secrets/eppa2_jwt_secret, which matchesJWT_SECRET_FILE. RS256 remains available through the key paths documented inpython/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_limitif 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.