Startup Health Checks: Validate Env & Probe Services Before Your App Accepts Traffic
Your application deployed. The CI pipeline went green. The health endpoint returned 200 OK. And then, three minutes later, the first real user request exploded in production with ECONNREFUSED because DATABASE_URL was never set in the new environment.
This class of incident is entirely preventable. The fix is not better monitoring or smarter alerting. It is making the application refuse to start when it is not ready to serve traffic.
This post covers exactly how to do that across two runtimes: NestJS and ASP.NET Core. The pattern is the same in both: validate environment variables first, probe every external service second, and only then bind the HTTP server and accept connections.

The Fail-Fast Principle
A service that starts in a broken state is worse than a service that refuses to start at all. When an app starts broken:
- The container orchestrator marks the instance as ready and routes real traffic to it
- Load balancers send users to it
- Errors surface as cryptic 500s, not clear deployment failures
- On-call gets paged at 2 AM instead of the deployment pipeline failing loudly at 10 AM
When an app refuses to start (exit(1)):
- The container orchestrator keeps the old pod running
- The deployment fails visibly with a clear error in the logs
- No user sees a broken response
- The engineer who deployed gets an immediate signal, not a delayed page
The goal is to move failures left, from runtime to startup time.
Part 1: Environment Variable Verification
The first gate is environment variables. Before any application logic runs, the process must verify that every required configuration value is present and non-empty.
The Wrong Approach: Default Fallbacks
This pattern is widespread, and it is a reliability hazard:
// ❌ WRONG
const dbPort = process.env.DB_PORT ?? '5432';
const dbHost = process.env.DB_HOST || 'localhost';
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
When DB_HOST is missing in production, the app silently connects to localhost, which either succeeds (connecting to the wrong local instance) or fails with a generic connection error that hides the real root cause. The app still starts. The health check still passes. The first request still fails.
?? 'default' and || 'fallback' on required environment variables mask misconfiguration. Required values must be required. If a value is absent, the process must refuse to start, not silently substitute a default.
The only acceptable defaults are for genuinely optional tunables (e.g., log level, request timeout) that have safe production values.
The Right Approach: Verify Required Variables at Startup
At startup, iterate over every variable your app needs and confirm it is present and non-empty. If anything is missing, log every absent variable at once and exit immediately.
// NestJS: main.ts, before NestFactory.create()
const REQUIRED_ENV = [
'DATABASE_URL',
'REDIS_URL',
'MINIO_ENDPOINT',
'MINIO_ACCESS_KEY',
'MINIO_SECRET_KEY',
'RABBITMQ_URL',
];
const missing = REQUIRED_ENV.filter((key) => !process.env[key]);
if (missing.length > 0) {
console.error('Missing required environment variables:', missing.join(', '));
process.exit(1);
}
// ASP.NET Core: Program.cs, before builder.Build()
var required = new[] {
"DATABASE_URL", "REDIS_URL",
"MINIO_ENDPOINT", "MINIO_ACCESS_KEY", "MINIO_SECRET_KEY",
"RABBITMQ_URL"
};
var missing = required.Where(k => string.IsNullOrWhiteSpace(builder.Configuration[k])).ToList();
if (missing.Any()) {
Console.Error.WriteLine($"Missing required environment variables: {string.Join(", ", missing)}");
Environment.Exit(1);
}
This is intentionally simple. The process exits with a non-zero code before a single module initializes, and the log tells the engineer exactly which variables to set.
Part 2: Service Connectivity Probes
Passing environment variable validation means the configuration values exist and are well-formed. It does not mean the services are reachable. The second gate is connectivity: probe every external dependency before the HTTP server starts.
Each probe follows the same structure: attempt the connection, verify the service responds correctly, and call process.exit(1) (NestJS) or stop the host application lifetime (ASP.NET Core) if anything fails. The log message must include the target host and the specific error. A message like "connection refused" with no context is not actionable.
Database
Send a lightweight query (SELECT 1 for SQL databases) to verify the connection is alive and the credentials are accepted. In NestJS with TypeORM, DataSource.initialize() is a natural probe; it validates the connection and runs any pending migrations. In ASP.NET Core, DbContext.Database.CanConnectAsync() is sufficient.
A failed database probe at startup is always fatal. Do not retry; if your database is unreachable when the app boots, the deployment itself is broken.
Redis
Send a PING command and assert the response is PONG. In NestJS use ioredis with lazyConnect: true and maxRetriesPerRequest: 1 so the client does not silently retry. In ASP.NET Core, StackExchange.Redis connects lazily by default; force an immediate connection and call GetDatabase().PingAsync().
MinIO / S3-Compatible Object Storage
A reachable MinIO endpoint is not enough; the target bucket must exist and your credentials must have access to it. Use HeadBucket (AWS SDK v3) or BucketExists (MinIO SDK for .NET) against the configured bucket name. This single call verifies the endpoint, credentials, and bucket existence simultaneously. If the bucket is missing or the credentials are wrong, crash. Retrying will not fix a misconfigured bucket name.
Message Queue
BullMQ uses Redis as its backing store. If the Redis probe passes, BullMQ connectivity is guaranteed. No additional probe is needed.
For RabbitMQ, open a connection using amqplib (NestJS) or RabbitMQ.Client (.NET), verify it succeeds, then close it immediately. This is a pure connectivity probe. Do not leave the probe connection open or reuse it for application messaging.
Part 3: Wiring It All Together
Probes must run before the HTTP server binds. This is the invariant that makes the pattern work.
In NestJS, run all probes in main.ts sequentially between NestFactory.create() and app.listen(). If any probe calls process.exit(1), the server never binds and the deployment fails cleanly.
In ASP.NET Core, implement each probe as an IHostedService. The framework guarantees that all IHostedService.StartAsync() calls complete before Kestrel begins accepting connections. Throw from StartAsync and call IHostApplicationLifetime.StopApplication(). The host shuts down with a non-zero exit code.
The startup sequence is:
- Verify required environment variables: missing var → log all absent keys,
exit(1) - Probe PostgreSQL: failure → log host + error,
exit(1) - Probe Redis: failure → log URL + error,
exit(1) - Probe MinIO bucket: failure → log endpoint + bucket + error,
exit(1) - Probe RabbitMQ: failure → log URL + error,
exit(1) - All checks passed. HTTP server binds and accepts traffic.
Part 4: Retry vs. Hard Fail
In containerized environments, dependent services may still be initializing when your app starts. A single connection attempt will fail even though the service is healthy; it just is not ready yet. Retrying with backoff handles this case without masking real failures.
The rule:
- Retry with backoff for connection-level failures (
ECONNREFUSED, timeout) where the service may be slow to start. 3 attempts with a 1s delay is a reasonable default - Hard fail immediately for authentication failures, missing buckets, or any error that retrying will not fix; a wrong password does not become correct on the third try
Implement a small probeWithRetry utility that wraps any probe function, retries on failure up to a configured maximum, and exits the process if all attempts are exhausted. The log at each retry should include the attempt number, the total allowed, and the specific error, so the engineer reading the logs can distinguish "service was slow to start" from "credentials are wrong."
Production Checklist
- No
?? 'default'or|| 'fallback'on required environment variables: absent means crash - All required env vars verified for presence at startup, before any module initializes
- Every external service has a dedicated connectivity probe
- Probes run before the HTTP server binds and accepts traffic
- Process exits non-zero (
exit(1)) on any check failure - Startup errors are logged with actionable context (which var, which host, which bucket)
- Retry logic added for transient connection failures in containerized environments
- CI/CD pipeline treats a failed deployment (non-zero exit) as a build failure, not a warning
Conclusion
Startup health checks are a one-time investment that permanently eliminates an entire class of production incident. The pattern is identical across runtimes: validate configuration first, probe connectivity second, bind the HTTP server last. If any step fails, exit loudly with enough context for the engineer to fix it in under a minute.
The two most common mistakes are default fallbacks (?? 'some-value') that mask missing configuration, and connectivity probes that run after the server starts accepting traffic. Both turn a deployment failure into a runtime incident. Both are trivially avoidable.
Make your app opinionated about its own readiness. If it is not ready, it should not start.
