Stateless REST API for patient record management — JWT authentication, Firestore persistence, graceful shutdown, multi-stage Docker build, and full GCP Cloud Run deployment via Terraform IaC.
be2-backend/ Spring Boot 4 REST API (Java 21, Gradle)
terraform/ GCP infrastructure (Cloud Run + Firestore + Artifact Registry)
Graceful shutdown with live drain count
ActiveRequestCounter is a plain servlet Filter that wraps every request with an AtomicInteger increment/decrement. On SIGTERM, ShutdownLogger marks the health endpoint 503 (so the load balancer stops routing), logs the live in-flight count, and @PreDestroy closes the Firestore client after Spring's 15s drain window. server.shutdown: graceful handles the rest.
PID 1 / SIGTERM forwarding in Docker
ENTRYPOINT ["sh", "-c", "java ..."] makes sh PID 1. Cloud Run sends SIGTERM to PID 1 — but sh -c doesn't forward signals to its child, so Spring's graceful shutdown never fires and Cloud Run falls back to SIGKILL. Fix: prefix with exec so the shell replaces itself with the Java process, making Java PID 1.
Build-only-on-change via source hash
A null_resource in Terraform computes a SHA-1 of be2-backend/src/** as its trigger. Cloud Build only runs when source actually changes — not on every terraform apply.
Alpine vs Noble base image
The Firestore SDK uses gRPC + netty-tcnative which requires glibc. Alpine uses musl — this causes a JVM SIGSEGV on startup. Base image: eclipse-temurin:21-jre-noble (Debian-based).
graph TB
Client -->|HTTPS| CR[Cloud Run<br/>patient-api]
CR -->|gRPC / ADC| FS[(Firestore<br/>Native Mode)]
CB[Cloud Build] -->|push| AR[Artifact Registry]
AR -->|deploy| CR
subgraph GCP Project
CR
FS
AR
CB
SA[Service Account<br/>roles/datastore.user]
end
SA -.->|identity| CR
TF[Terraform] -->|provisions| CR
TF -->|provisions| FS
TF -->|provisions| AR
TF -->|provisions| SA
Request flow: HTTPS → Cloud Run → Firestore (Native Mode)
Security: JwtAuthFilter extends OncePerRequestFilter, validates the Bearer token, and sets SecurityContextHolder. Expired → 401; invalid → 403. SecurityConfig is STATELESS; /auth/login and /health are public; /patients/** requires JWT.
Option A — Firestore emulator (no GCP credentials needed)
# Terminal 1: start emulator
gcloud beta emulators firestore start --host-port=127.0.0.1:8090
# Terminal 2: run app
cd be2-backend
FIRESTORE_EMULATOR_HOST=127.0.0.1:8090 \
JWT_SECRET=this-is-a-32-byte-secret-key-here \
./gradlew bootRunOption B — Real Firestore
cd be2-backend
GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json \
JWT_SECRET=this-is-a-32-byte-secret-key-here \
GCP_PROJECT_ID=your-gcp-project-id \
./gradlew bootRun| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/auth/login |
No | Authenticate, receive JWT |
GET |
/patients |
JWT | List all patients |
GET |
/patients/{id} |
JWT | Get patient by ID (404 if missing) |
POST |
/patients |
JWT | Create patient (server-generated UUID) |
GET |
/health |
No | 200 healthy / 503 shutting down or DB disconnected |
GET |
/slow?delay=N |
No | Sleeps N seconds (max 30) — graceful shutdown demo |
Demo credentials: admin / password123, doctor / secret456
terraform apply provisions everything in one command:
| Resource | Purpose |
|---|---|
| Artifact Registry | Docker image storage |
| Firestore (Native Mode) | Document database |
| Service Account | Cloud Run identity (roles/datastore.user) |
| Cloud Build | Builds and pushes container image |
| Cloud Run | Serverless compute, public HTTPS |
cd terraform/
cp terraform.tfvars.example terraform.tfvars # fill in your values
terraform init
terraform applyterraform destroy # tear down everything cleanlyUses a named Firestore database (not (default)) so terraform destroy can cleanly delete it.
# Build
docker build -t patient-api:local ./be2-backend
# Run (with Firestore emulator)
docker run -p 8080:8080 \
-e JWT_SECRET=this-is-a-32-byte-secret-key-here \
-e GCP_PROJECT_ID=local-dev \
-e FIRESTORE_EMULATOR_HOST=host.docker.internal:8090 \
patient-api:local| Stage | Base Image | Purpose |
|---|---|---|
| Build | eclipse-temurin:21-jdk-noble |
Gradle build, produces fat JAR |
| Run | eclipse-temurin:21-jre-noble |
JRE only, non-root user |
This project was built using Claude Code (Anthropic) as the primary development tool throughout — code generation, debugging, architecture decisions, and documentation.
My role was directing the overall approach, reviewing and approving AI-generated output, and redirecting when something was wrong or incomplete. The key decisions — GCP over AWS, Firestore over Cloud SQL, Terraform from scratch after learning it via Google Labs, the exec fix for PID 1 signal forwarding, the source-hash Cloud Build trigger — were made by me after understanding the tradeoffs.
AI-assisted development is increasingly the norm. I'm being upfront about it here because the interesting part isn't who typed the code — it's the engineering judgment behind the decisions.
| Variable | Required | Description |
|---|---|---|
JWT_SECRET |
Yes | HMAC-SHA256 signing key (min 32 chars) |
GCP_PROJECT_ID |
Yes (prod) | GCP project ID. Defaults to local-dev |
FIRESTORE_DB |
No | Firestore database name. Defaults to (default) |
GOOGLE_APPLICATION_CREDENTIALS |
Local only | Path to service account JSON key |
FIRESTORE_EMULATOR_HOST |
Local only | Firestore emulator address |
See be2-backend/README.md for full API docs, 12-Factor compliance details, and test coverage.