Deployment
Slim by design: a single Docker image built by sbt-native-packager, configured by environment variables, and run wherever you run containers (k8s, ECS, Nomad, plain docker run).
The repo doesn’t ship infrastructure-as-code, a Helm chart, or a CI pipeline. Those are deployment-target-specific; you bring them. What’s here is the bit every deployment needs: a reproducible image and a clear list of what to feed it.
Building the image
Section titled “Building the image”sbt-native-packager is enabled in build.sbt:
enablePlugins(JavaServerAppPackaging)dockerCommands += Cmd("ARG", "BUILD_VERSION")dockerCommands += Cmd("ENV", "APP_VERSION=$BUILD_VERSION")dockerRepository := sys.env.get("DOCKER_REPO")packageName := "madrileno"dockerBaseImage := "azul/zulu-openjdk:21"Docker / daemonUser := "noroot"dockerUpdateLatest := trueTwo build commands cover the lifecycle:
# Build the image into the local Docker daemon.sbt --client "Docker/publishLocal"
# Build and push to the registry pointed at by DOCKER_REPO.DOCKER_REPO=ghcr.io/your-org sbt --client "Docker/publish"Docker/publishLocal produces madrileno:1.0.0-SNAPSHOT (matching version in build.sbt) and madrileno:latest. Docker/publish is the same plus a push to $DOCKER_REPO/madrileno:<version> and $DOCKER_REPO/madrileno:latest.
To stamp the image with a meaningful version (git SHA, semver tag, CI build number), pass it through BUILD_VERSION:
docker build --build-arg BUILD_VERSION=$(git rev-parse --short HEAD) ...build.sbt exposes that as the APP_VERSION env var inside the running container, which application.conf then reads via version = ${?APP_VERSION}. Anywhere the app reports its version (logs, OTEL service.version), the value is whatever you passed.
What’s in the image
Section titled “What’s in the image”- Base:
azul/zulu-openjdk:21. Production-grade JDK, regular security updates, multi-arch. - Daemon user:
noroot. The app doesn’t run as root. - Layout: native-packager’s standard — a launcher script under
/opt/docker/bin/madrileno, JAR + dependencies under/opt/docker/lib/, configuration via JVM flags + env vars. - Entry point:
bin/madrileno. Launches the JVM with the project’sMain.
The image is not multi-stage; it’s whatever JavaServerAppPackaging builds plus the two extra commands for BUILD_VERSION. If you want a slimmer base (Alpine, distroless), swap dockerBaseImage. If you want a buildkit-style multi-stage build for shrink, drop the native-packager defaults and write your own Dockerfile referencing the universal package output (sbt --client "Universal/packageBin").
Running the image
Section titled “Running the image”The container needs environment variables for every ${?VAR} substitution in application.conf. See configuration.md for the full list; the production-relevant subset is:
- App identity —
APP_ENVIRONMENT=prod,APP_VERSION=<git sha>. - HTTP —
INTERFACE,PORT,BASE_URL,MAX_REQUEST_SIZE. Bind to0.0.0.0inside the container; expose the port via your orchestrator. - Postgres —
PG_HOST,PG_PORT,PG_DATABASE,PG_USER,PG_PASSWORD. Provision with whatever your platform offers (RDS, Cloud SQL, managed Postgres). - OpenTelemetry —
OTEL_*per observability.md. Point at your real OTLP receiver. - Mailer —
MAILER_HOST,MAILER_PORT,MAILER_USERNAME,MAILER_PASSWORD,MAILER_FROM_ADDRESS,MAILER_TLS=true. SES, SendGrid, internal SMTP relay — anything that speaks SMTP. - Auth —
JWT_SECRET(real secret; inject from your secrets manager, never bake into the image).FIREBASE_PROJECT_IDif you use Firebase login — that’s just the project id (not a secret); ID-token verification fetches Google’s public certs. Leave it unset to disable Firebase auth. - Object storage —
S3_ENDPOINT,S3_REGION,S3_BUCKET,S3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEY. Point at real S3 or any S3-compatible store. - Admin auth —
ADMIN_USER,ADMIN_PASSWORD. Gates/admin/*(health, jobs UI, mail previews in dev). Strong password.
The dev-stack .env is not a production template; it’s a local-development convenience. For real deploys, generate the env from your platform’s secrets store + non-secret config map.
Migrations are a separate step
Section titled “Migrations are a separate step”The app does not auto-migrate on startup. Run them as a discrete deploy step before rolling out the new image:
build image → push image → run migrations → roll out new podsThe Docker image ships a second launcher, bin/migrate-main, that runs Flyway against the same PG_* env vars the app reads. Same image, same Git SHA, same config — schema and code can’t drift apart. Run it as a one-shot container (or whatever your platform’s equivalent is) gated before the new pods roll out.
Two reasons this is the right separation:
- Migrations are slow and rare. Running them on every pod start would slow rollouts and make crash-loop debugging painful.
- Migrations are dangerous. Locking, long-running, can fail mid-way. They want a single owner per deploy, not N pods racing.
For zero-downtime rollouts, follow the standard expand/contract dance:
- Migration that’s backwards-compatible with the old code (add column, NOT NULL with default).
- Deploy new code that uses the new shape.
- Cleanup migration in a later deploy (drop old column, etc.).
Graceful shutdown
Section titled “Graceful shutdown”On SIGTERM the app drains in-flight requests before exiting — rolling deploys don’t cut active connections. This is mostly emergent behavior from cats-effect’s resource composition; you don’t need to do anything to get it.
What happens, in order:
- The JVM receives
SIGTERM. IOApp.Simplecancels the main fiber. The cancel propagates through theResource.use { IO.never }inMain.scala.Resourcefinalizers run in reverse acquire order. So shutdown is server → scheduler → object store → DB transactor → OTel SDK — exactly the inverse of how they were started.- The Ember HTTP server’s release:
- Stops accepting new connections (new requests get
Connection refused). - Waits up to
http.shutdown-timeout(default30s) for in-flight requests to complete. - Any request still in flight after the timeout is force-closed.
- Stops accepting new connections (new requests get
- The scheduler stops polling for new task rows. Tasks already executing run to their next checkpoint.
- The DB transactor closes the connection pool.
- The OpenTelemetry SDK’s shutdown is called — this flushes pending exporter batches (traces, metrics, logs).
Configure the drain window with HTTP_SHUTDOWN_TIMEOUT (HOCON http.shutdown-timeout). Pick something a little longer than your worst-case request — for an app that serves p99 = 5s requests, 30s is plenty. For long-polling or streaming endpoints, raise it. Whatever the orchestrator’s “give the process this long to exit after SIGTERM” knob is, set it to at least the same value — otherwise it’ll SIGKILL mid-drain.
What’s NOT done out of the box:
- In-flight scheduler tasks are hard-canceled with the rest of the resource graph (whatever cats-effect cancellation lets them complete). If you have long-running tasks that need to checkpoint, they should do so frequently rather than relying on a graceful interrupt.
- OTel exporter flush timeout is whatever the SDK defaults to. If you’re seeing dropped traces at shutdown, configure
OTEL_BSP_EXPORT_TIMEOUT(env var) higher.
Health checks
Section titled “Health checks”The deep /admin/health-check endpoint exists (Postgres + SMTP probe) but it’s gated by Basic Auth — designed for human ops checks, not orchestrator probes. For Kubernetes-style probes, hit /v1/health-check (unauthenticated, lightweight). See http.md and the HealthCheckModule for what’s wired.
If you need a richer liveness/readiness probe, add an unauthenticated endpoint (or a separate port) that runs the same checks without the auth gate. Don’t expose /admin/* to the orchestrator.
What this template doesn’t ship
Section titled “What this template doesn’t ship”Things you’ll likely want and need to add yourself:
- CI pipeline —
sbt verifyAllis the gate locally; wire it (orcompile + testFull + scalafmtCheckAll) into GitHub Actions / GitLab CI / whatever you use. UsetestFull, nottest— sbt 2’stestis cached and can under-run (see dev-workflow.md). - Image scanning — Trivy, Snyk, your registry’s own scanner.
- Helm chart / k8s manifests / Terraform — too target-specific to live here.
- Secrets management — assumes you have one (AWS Secrets Manager, k8s secrets, Vault). The app reads env vars; the secrets system is responsible for getting them there.
- Blue/green or canary tooling — orchestrator-level concern.
The image and the env-var contract are stable; the rest is yours to assemble.
Where to look next
Section titled “Where to look next”- configuration.md — the full env-var contract.
- observability.md — what to point
OTEL_*at; what shows up where. - database.md — migration mechanics.
- dev-workflow.md — running migrations locally (
runMain madrileno.main.MigrateMain) and itsinfo/validate/cleansubcommands.