Logging & Monitoring
docker logs <container> over SSH works for a quick check, but doesn’t
scale past that — no search, no history beyond the log driver’s own
rotation, no dashboards. Two setups cover local development and production,
sharing the same query language so the same searches work in both.
What gets logged
- Application/business logs — regular application logging calls, plus repository-call timing at debug level.
- Per-request logs — one line per completed HTTP request:
METHOD /path -> status (Nms). Without this, only what application code explicitly logs shows up — a request to an endpoint with no logging of its own would leave no trace. Each request also gets a correlation ID stamped into every log line it produces, so searching by that ID surfaces the whole request’s log trail in one query.
Local development: self-hosted Loki + Grafana
A separate compose file adds Loki (log storage), Promtail (tails the API container’s logs), and Grafana (query/dashboard UI) — gated behind a Compose profile so it’s never started by accident and never touches production.
docker compose -f docker-compose.yml -f docker-compose.logging.yml --profile logging up -dOpen the local Grafana UI and query {container="<your-api-container-name>"}
in Explore to see live logs.
# Stop
docker compose -f docker-compose.yml -f docker-compose.logging.yml --profile logging downProduction: Grafana Alloy → Grafana Cloud (free tier)
A resource-constrained VPS (e.g. a cloud provider’s free tier, ~1GB RAM) is
too tight to run Loki+Grafana locally alongside the API. Instead, a
lightweight shipper (Grafana Alloy, ~30–40MB RAM) reads the API container’s
logs via the Docker socket and forwards them to Grafana Cloud’s hosted Loki
(free tier: 50GB/month ingestion). Gated behind its own Compose profile —
zero resource cost when disabled, since a profile-gated service is
simply never started by a profile-less docker compose up -d.
Create a Grafana Cloud account
Free tier, at grafana.com. Note your stack’s Loki instance details (Connections → Data sources → Loki → Details page shows the push URL and instance/user ID).
Create an API key
My Account → Security → API Keys, with logs:write scope.
Add repo secrets
GRAFANA_CLOUD_LOKI_URL, GRAFANA_CLOUD_LOKI_USER, GRAFANA_CLOUD_API_KEY.
GRAFANA_CLOUD_LOKI_URL must be the full push endpoint, not just the
host — it has to end in /loki/api/v1/push. The Loki details page shows
this full URL; don’t trim it to just the bare host. Pointing the shipper
at the bare host makes every push fail with a “method not allowed” error,
since the host root doesn’t accept the push method — only the specific
push path does.
Enable the shipper via a repo variable
Set the GitHub repo Variable (not secret — it’s not sensitive) that
gates cloud logging to true. Re-run the deploy workflow — it checks this
variable and starts the API with the logging profile enabled instead of the
plain form when it’s true.
Verify logs are arriving
In the Grafana Cloud web UI, go to Explore, pick the Loki-typed datasource
for your stack (its name follows <stack-name>-logs — not literally
labeled “Loki” in the picker, though the type-badge next to it says so; two
other Loki-typed entries are Grafana’s own internal telemetry, not your
app’s logs — ignore those), and query {container="<your-api-container-name>"}.
To turn cloud logging back off, unset the repo variable and re-run the deploy workflow — or stop the already-running shipper container directly on the server if you don’t want to wait for the next deploy.
The exact same query built locally works unmodified in the Grafana Cloud web UI once shipping — only the datasource differs, not the query language.
Health checks
A narrow health endpoint is wired in — exposing only the health check,
none of the framework’s other diagnostic endpoints, and with response
detail suppressed so no internal connection details leak to an
unauthenticated caller. Because the datasource is on the classpath, the
health check automatically verifies database connectivity too — a DOWN
status means either the app itself or its database connection is unhealthy,
not just “the process happens to still be running.”
Both the container image and the compose file define a healthcheck that polls this endpoint every 15 seconds — so a plain container-status glance shows the true healthy/unhealthy state instead of a bare “running” that’s true even while the app is crash-looping internally.
External uptime monitoring
Point an external uptime checker at the health endpoint’s public URL on a 1–5 minute interval, alerting the moment it stops returning success. Two common free options: a synthetic-monitoring feature bundled with your logging provider (no separate signup), or a dedicated uptime-check service.
This is the single highest-leverage monitoring addition for a small deployment: it catches outages (origin unreachable, container crash-looping, database connection lost) within minutes, automatically — instead of relying on someone noticing the site is down.