Deployment Readiness Checklist
A checklist of actions to review before deploying an app to production.
README.md
- About the app (its purpose)
- How to build
- How to start
- Dependencies (DB, external services)
- Link to the OpenAPI/Swagger spec (or generated docs), if the app exposes an API
- Link to the architecture diagram / ADRs for anything non-trivial
- Link to the runbook and on-call/escalation contact for this service
- Local development setup is documented and reproducible (e.g.
docker-compose up, seed data, required tooling versions)
Variables & secrets
The app is configured via environment variables plus a
.envor config fileEnvironment variables take priority over configuration files (but have lower priority than command-line arguments)
For static frontend apps, variables are configured at runtime via a
config.jsonfile - avoid setting them at the CI stepThe app validates all required config/secrets at startup and fails fast with a clear error, instead of failing on the first request that needs them
No default values for secrets (JWT secret, DB password, API keys) - a missing secret in production should refuse to boot, not silently fall back to a dev default
Secrets are stored in a dedicated secret manager (Vault, AWS/GCP Secrets Manager, sealed-secrets, K8s Secrets + encryption at rest) - never committed to git, even in "private" repos
Secrets differ per environment (dev/staging/prod); no environment ever reuses another's credentials
Secrets and API keys are rotated on a defined schedule, and rotation doesn't require a full redeploy
Access to production secrets is scoped and audited (who can read/write them, and when they last did)
The app exposes the following configurable environment variables:
SERVER_HOST&SERVER_PORT/SERVER_ADDR(e.g.0.0.0.0:8080)HTTP_READ_TIMEOUT&HTTP_WRITE_TIMEOUT/HTTP_TIMEOUT
Note:
HTTP_READ_TIMEOUT- the maximum duration for reading the entire request, including the body. Covers only client request read and validation, and should always be fast (e.g. ~1s).HTTP_WRITE_TIMEOUT- the maximum duration before timing out the response write. It resets whenever a new request's headers are read, and covers the whole response time (e.g. ~10s).
The app should correctly handle timeouts: cancel DB queries and close connections when they occur.
References:
- https://adam-p.ca/blog/2022/01/golang-http-server-timeouts
- https://www.alexedwards.net/blog/how-to-manage-database-timeouts-and-cancellations-in-go
- https://pkg.go.dev/net/http#TimeoutHandler
Docker
The app is Dockerized, and a
docker-compose.ymlis present for dependencies, if anyA
.dockerignorefile is present:.git
.gitlab-ci.yml
DockerfileAvoid running as root (e.g.
USER node)Only one process runs -
execto the process directly (not vianpm run)A
docker-entrypoint.shis used if the app has separate runtime logic (e.g. web + workers) or runs migrationsThe container filesystem is read-only where possible (
readOnlyRootFilesystem: true), with explicit writable mounts (e.g./tmp) only where needed
Docker image
- The image should be as small as possible - under 200 MB is acceptable.
- Alpine-based, if possible
- Multi-staged (separate builder stage)
- Build cache is cleaned (
npm cache clean --force) - Only production dependencies are installed -
npm ci --productionis preferred overnpm install --only=prodor cleaning up afterward withnpm prune --production
- The image is scanned for known CVEs (Trivy/Grype) as a CI gate, not just ad hoc
- The image is pinned to a digest or an immutable tag in production manifests - never
:latest - The base image is kept up to date and rebuilt periodically, even without app code changes, to pick up OS-level security patches
App runtime
- The app is horizontally scalable
- If the app has separate logic (e.g. web + worker), each runs as its own process so they can scale independently
- Healthchecks are separated accordingly
- Avoid unique endpoints like
POST /users/{UUID}/resend-email, as they generate metric and trace spam - alternatively, strip unique path segments and query parameters from metrics and traces - For the frontend apps all static files should be requested with extra params based on code version, it allows to reset browser cache after releasing new version:
image123.webp?v=v1.4.10 - The frontend apps should utilize cdn for the static files
- The app is stateless - no in-memory session/cache that would break when a second replica starts (use Redis/DB/sticky sessions deliberately, not by accident)
- A load/performance test has been run at least once before launch, with a documented baseline (requests/sec, p95/p99 latency, breaking point) to compare against future regressions
- CORS is explicitly configured (an allow-list of origins, not
*) if the API is called from a browser - Feature flags exist for risky changes, so a bad feature can be turned off without a redeploy
- The frontend apps should have a built-in global maintenance page that can be enabled via the BFE API or through
config.json - All outbound calls (HTTP, DB, queue) have an explicit timeout - no call can block indefinitely
- Retries on transient failures use exponential backoff with jitter, and are capped (a retry storm shouldn't be able to take down a struggling dependency)
- A circuit breaker (or equivalent) protects calls to unreliable external dependencies, so one slow dependency can't exhaust the app's own connection pool or threads
- The app degrades gracefully when a non-critical dependency is unavailable (e.g. serve cached/stale data or a reduced feature set) rather than failing the whole request
- The app separates read and write operations, allowing it to use database read replicas
Health and metrics
-
/healthacts as a readiness probe and checks critical dependencies (DB); if the app has several dependencies, consider adding extra healthchecks such as/health/db,/health/kafka, etc. -
/or/statusacts as a liveness probe (confirms the app is alive and not stuck - otherwise it will be restarted) and exposes some static app/build info -
/health-srvchecks third-party service availability -
/metricsexposes Prometheus metrics - requests per endpoint, error rate, request duration
Logs and errors
More logging is better.
- All requests are logged, with extra messages for responses
- Log levels are used consistently: debug, info, warn, error
- No logging to file
- All logs are human-readable and written to
STDOUT/STDERR - Errors are sent to Sentry (add a
SENTRY_ENVvariable for the environment tag) - GELF support, with JSON logs
- Logs are structured (JSON) with consistent field names (
timestamp,level,message,request_id), not freeform strings - this keeps them queryable in ELK/Loki - No PII or secrets (passwords, tokens, full card numbers) ever land in logs - redact or mask before logging
- Log volume and sampling are considered for high-throughput endpoints, so verbose logging doesn't overwhelm the logging pipeline or blow the budget
- A log retention period is defined and enforced by the logging backend, consistent with the data retention policy
Metrics
- Requests and responses counter, labeled with method, endpoint, and status code
- Response quantile summary metric
- Request duration histogram
- Any other measurements relevant to the app's core logic (e.g. job status and duration)
- Key business/product metrics are tracked alongside technical metrics where relevant (e.g. signups, payments processed), so a regression in business impact is visible, not just infra health
- Dashboards exist for the service (not just raw metrics) and are linked from the README/runbook, so an on-call engineer isn't building queries from scratch during an incident
Client libraries: https://prometheus.io/docs/instrumenting/clientlibs/
Examples:
Alerts
Check for anomalies against a baseline - preferably app-level metrics; infra (nginx/linkerd) metrics only as a fallback.
- Request rate
- Error rate
- Latency
- CPU/RAM
- Saturation of connection pools (DB pool, HTTP client pool) - pool exhaustion is often the first sign of an incident, appearing before latency or error rate move
- Job not running / job failures
- SLOs (service level objectives) are defined for the service's key user journeys (e.g. "99.9% of checkout requests succeed in under 500ms"), and alerts fire on error-budget burn rate, not just raw threshold breaches
- Alerts are actionable and routed to the right on-call rotation - an alert nobody can act on, or that pages the wrong team, trains people to ignore alerts
- Alert thresholds are periodically reviewed to reduce noise (flapping alerts erode trust in the whole alerting system)
Tracing
- The app handles the
X-Request-IDheader; if not present, it generates a random string or UUID - The request ID is added to the
request_idlog field on all logs - Application Performance Monitoring (APM) is in place
- All external calls are covered by traces (other apps, DBs, queues, etc.)
- Trace context propagates across service boundaries (e.g. W3C Trace Context headers), so a single request can be followed end-to-end across the whole call graph, not just within one service
Elastic APM as an example: https://www.elastic.co/guide/en/apm/agent/index.html
Graceful shutdown
-
SIGINT(Ctrl+C) andSIGTERMare handled - in-flight requests complete, keep-alive connections close, and DB connections close - The app exits on critical/fatal errors
- The readiness probe starts failing as soon as shutdown begins, so the load balancer/ingress stops routing new traffic before the process actually exits
- In-flight background jobs/queue consumers finish or checkpoint their current unit of work before exiting, rather than being killed mid-task
DB
- DB connections use a pool
- Pool size is managed via environment variables
- All DB resources (tables, indexes, functions, policies, etc.) are created via a DB migration job
- Database migrations are forward-only - production migrations must not rely on
down/rollback migrations - Migrations are backward-compatible with the previous app version (expand/contract pattern: add columns before removing them, deploy code before dropping columns), so a rolling deploy never has old code hitting a new schema mid-rollout
- Destructive database changes are isolated into a separate migration and are only executed after the application no longer depends on the old schema
- Rollback is performed by deploying the previous application version, not by reverting the database schema
- Slow-query logging is enabled; N+1 queries are checked for on hot paths (executed frequently or performance-critical)
- Read replicas (if used) have documented replication-lag expectations, and the app tolerates eventually-consistent reads where they're used
CI
- Builds run inside Docker containers
- The image tag is never
latest:- GitLab CI:
DOCKER_IMAGE_TAG="$CI_COMMIT_REF_SLUG-$COMMIT_DATE-$CI_PIPELINE_ID"for - GitHub actions:
DOCKER_IMAGE_TAG="${GITHUB_REF_SLUG}-${COMMIT_DATE}-run-${GITHUB_RUN_NUMBER}"withgithub-slug-action
- GitLab CI:
- Lint and test steps are included
- Images are published to DockerHub
- Slack notifications are configured
- The DockerHub repo is set to private (it's public by default)
- GitLab environment variables are used:
environment.nameandenvironment.url - A cleanup step removes created images and intermediate build images
- Dependency versions are pinned (lockfile committed:
go.sum,package-lock.json, etc.) - A dependency vulnerability scan (
npm audit,govulncheck, Snyk/Trivy) runs as a blocking or at least visible step - Dependabot/Renovate (or equivalent) is enabled so dependency updates are proposed automatically, rather than discovered during an incident
- Static analysis / SAST scanning runs in the pipeline (e.g. Semgrep, gosec, ESLint security rules)
- Secrets scanning runs on every commit/MR (e.g. gitleaks, trufflehog) to catch accidentally committed credentials before merge
CD
- CI and CD are separated — CI builds, tests, and publishes immutable artifacts; CD promotes and deploys an already-built artifact without rebuilding it
- Only artifacts built and tested by CI are deployed - nothing is built directly from a developer machine or deployed from an unverified/untagged branch
- Production deployments use an immutable artifact reference (image digest or immutable version tag), never
latestor a mutable tag - The exact artifact deployed to production is traceable to the source commit, CI pipeline, and test results that produced it
- Deploys to production require passing through lower environments first (dev → staging → prod), except for documented emergency-fix paths
- Configuration changes are version-controlled and reviewed; production configuration is not changed manually without an audit trail
- The rollout strategy is explicit (rolling update, recreate, blue-green, or canary), with
maxSurge/maxUnavailable(or equivalent) tuned for the service's traffic pattern. If recreate strategy is used, the expected downtime is explicitly accepted and documented, and the service has an appropriate maintenance/availability plan - The deployment maintains sufficient healthy capacity throughout the rollout - a deployment must not intentionally take the service below its required availability level
- The deployment waits for new instances to become ready before terminating healthy old instances
- Rollout progress and deployment health are observable (ready replicas, error rate, latency, saturation, and application-specific health signals)
- Automated smoke tests run against the environment immediately after deploy, before the deploy is considered complete
- Critical user journeys are verified after deployment, not only that the process/container started successfully
- Rollback is a tested, one-step operation (redeploy the previous immutable image / revert the deployment manifest) - not something improvised for the first time during an incident
- The previous known-good artifact remains available for immediate rollback
- Rollback does not depend on rebuilding the previous version
- Rollback is automated or triggered on defined signals (error-rate spike, failed smoke test, crash-loop, readiness failure, or significant latency regression), not solely dependent on a human noticing in time
- Database migrations are backward-compatible with the currently running application version, so application rollback does not require a database rollback
- Destructive database/schema changes are deployed separately from the code that starts depending on them and follow the forward-only / expand-contract migration strategy
- Deployments have a defined timeout; a rollout that does not become healthy within the expected time is considered failed
- Failed deployments do not leave a partially updated production environment without being clearly reported and handled
- Deploys are visible to the team (Slack notification, changelog, release notes) so people know what changed and when
- Deployment notifications include at least the service, environment, version/artifact, source commit, deployer/trigger, and deployment status
- Risky deploys avoid peak-traffic windows and periods of reduced on-call coverage (e.g. Friday evening, holidays), unless there is a documented reason to deploy
- Production deployments have an identified owner/on-call engineer who can monitor the rollout and perform a rollback
- There is a documented emergency deployment path that bypasses normal promotion requirements while preserving auditability
- Deployment frequency and change-failure rate are tracked, so the team has a baseline for whether the deployment process is getting safer or riskier over time
API design
- Pagination on every list endpoint - never return an unbounded collection. Prefer
page/page_size(or cursor-based pagination for very large/real-time tables) with a sane default and a hard max page size - Input is validated at the boundary (schema/struct validation) before reaching business logic - reject bad input with a 400, don't let it become a 500 three layers down
- Idempotency for
PUT/DELETE(calling twice has the same effect as once); consider idempotency keys forPOSTon payment/order-creation-type endpoints - Rate limiting/throttling per client (API key or user), on top of any infra-level limiting - this protects the app even if the edge limiter is misconfigured or bypassed
- Response compression (gzip) is enabled for larger payloads
- Filtering/sorting on list endpoints uses an explicit allow-list of fields - never pass a raw client-supplied field name straight into an
ORDER BY - API versioning strategy is explicit (URL path, header, etc.), so breaking changes can ship without breaking existing consumers
Kubernetes / orchestration
- Separate
readinessProbe,livenessProbe, and (for slow-starting apps)startupProbeare configured - conflating these causes either premature restarts or traffic being sent to a not-yet-ready pod - CPU and memory
requestsare set, along with memorylimits. Set CPUlimitsonly in edge cases where throttling the app is preferable to starving neighboring workloads -
terminationGracePeriodSecondsmatches how long graceful shutdown actually takes - too short, andSIGKILLcuts off in-flight requests -
lifecycle.preStop.exec.command: ["sleep", "3"]is set, to avoid 502s while the ingress-nginx upstream syncs (every 1s) -
podDisruptionBudget.minAvailable: 2is set, so voluntary disruptions (node drains, cluster upgrades) don't take out every replica at once -
HorizontalPodAutoscaler(or equivalent) is configured for services with variable load, with sane min/max replica bounds - Pod anti-affinity (or topology spread constraints) spreads replicas across nodes/availability zones, so a single node or AZ failure doesn't take out every replica
Backups & disaster recovery
- Automated, encrypted backups exist for every stateful dependency (DB, object storage, message queue state, if it matters)
- Backups are stored somewhere that survives losing the primary environment (a different region/account)
- A restore has actually been performed end-to-end at least once - untested backups routinely turn out to be broken
- RTO (recovery time objective) and RPO (recovery point objective) are documented, even informally - "how much data/downtime can we tolerate" should be a decision, not a surprise
Compliance & data retention
- A data retention policy is defined - specify how long data is retained, what user data is stored in each type of storage, and whether an automated deletion process exists to prevent uncontrolled data growth
- PII is identified and specifically protected (encrypted at rest where appropriate, excluded from logs - see the Logs section)
- A documented process exists for handling a user's data-deletion request (GDPR "right to be forgotten" or equivalent), where applicable to your jurisdiction/users