02 · Production Server-Side Swift¶
Getting a server-side Swift app running (Level 3, Module 02) is different
from getting it production-ready: structured logging, environment-driven
configuration, health checks, and graceful shutdown. This module builds
those concerns with swift-log — the same structured logging API Vapor
itself uses internally — since it has no C/TLS dependency and compiles
cleanly in any environment.
Structured logging with swift-log¶
swift-log is the community-standard logging API for server-side Swift.
Instead of print, you get log levels and structured metadata that a real
log aggregator (Datadog, CloudWatch, etc.) can parse:
// Package.swift dependency:
// .package(url: "https://github.com/apple/swift-log.git", from: "1.6.0")
import Logging
LoggingSystem.bootstrap(StreamLogHandler.standardOutput)
let logger = Logger(label: "com.example.prodserver")
logger.info("Starting server", metadata: ["port": "8080", "env": "production"])
LoggingSystem.bootstrap is called exactly once, near the start of
main, to choose where logs go — StreamLogHandler.standardOutput prints
to stdout, but the same Logger API works unchanged if you swap in a
handler that ships logs to a remote aggregator instead.
Configuration from environment variables¶
Production services take their configuration (port, database URL, feature flags) from the environment, not hardcoded values — this is what makes the same build deployable to staging and production without a recompile:
import Foundation
struct Config {
let port: Int
let environment: String
static func fromEnvironment() -> Config {
let port = Int(ProcessInfo.processInfo.environment["PORT"] ?? "") ?? 8080
let env = ProcessInfo.processInfo.environment["APP_ENV"] ?? "development"
return Config(port: port, environment: env)
}
}
let config = Config.fromEnvironment()
logger.info("Starting server", metadata: ["port": "\(config.port)", "env": "\(config.environment)"])
Every environment-derived value has a sensible default (8080,
"development") so the binary still runs locally without any environment
variables set — production deployments override them via the process
environment (a docker run -e, a Kubernetes manifest, a .env loaded by
the platform).
Health checks¶
A /health endpoint (wired to real routing the way Level 3 Module 02
covers) needs a status your infrastructure's load balancer or orchestrator
can act on — healthy traffic gets routed to, unhealthy gets pulled out
of rotation:
enum HealthStatus: String { case healthy, degraded, unhealthy }
struct HealthCheck {
var databaseReachable = true
var diskSpaceOK = true
var status: HealthStatus {
if !databaseReachable { return .unhealthy }
if !diskSpaceOK { return .degraded }
return .healthy
}
}
var health = HealthCheck()
logger.info("Health check", metadata: ["status": "\(health.status.rawValue)"])
health.databaseReachable = false
logger.warning("Database unreachable", metadata: ["status": "\(health.status.rawValue)"])
Distinguishing degraded from unhealthy matters in practice: a
degraded instance (low disk, one of several caches unreachable) might stay
in rotation while paging someone, whereas unhealthy should be pulled
immediately.
Graceful shutdown¶
Production deployments send SIGTERM (or, interactively, SIGINT via
Ctrl-C) before killing a process — catching it lets you finish in-flight
requests and close connections cleanly instead of dropping them:
let shutdownSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
signal(SIGINT, SIG_IGN) // tell the OS's default handler to step aside
shutdownSource.setEventHandler {
logger.info("Received SIGINT, shutting down gracefully")
exit(0)
}
shutdownSource.resume()
signal(SIGINT, SIG_IGN) disables the default "terminate immediately"
behavior so DispatchSource's handler gets a chance to run its own cleanup
logic (flush logs, close database connections, finish in-flight requests)
before the process actually exits.
Full run¶
Running the whole program (PORT=9090 APP_ENV=production swift run):
2026-08-26T22:24:55+0530 info com.example.prodserver: env=production port=9090 [prodserver] Starting server
2026-08-26T22:24:55+0530 info com.example.prodserver: status=healthy [prodserver] Health check
2026-08-26T22:24:55+0530 warning com.example.prodserver: status=unhealthy [prodserver] Database unreachable
2026-08-26T22:24:55+0530 info com.example.prodserver: [prodserver] Server ready, simulating uptime
2026-08-26T22:24:55+0530 info com.example.prodserver: request_id=1 [prodserver] Handling request
2026-08-26T22:24:55+0530 info com.example.prodserver: request_id=2 [prodserver] Handling request
2026-08-26T22:24:55+0530 info com.example.prodserver: request_id=3 [prodserver] Handling request
2026-08-26T22:24:55+0530 info com.example.prodserver: [prodserver] Demo complete, exiting without a real interrupt
Each log line carries the level (info/warning), the logger's label, and
structured key=value metadata — exactly the shape a log aggregator's
query language (e.g. filtering on status=unhealthy) expects.
Swift-specific traps¶
LoggingSystem.bootstrapcan only be called once per process — calling it a second time (say, once in a library and again in the app) triggers a runtime crash by design, so libraries should never call it themselves; only the executable'smainshould.- Metadata values in
swift-logareLogger.MetadataValue, not raw strings —"\(config.port)"works becauseStringconforms via.stringConvertible, but passing a rawIntdirectly is a type error; string-interpolating simple values, as above, sidesteps this. ProcessInfo.processInfo.environmentis a snapshot taken once — it doesn't reflect environment variables changed after the process starts, which is expected (and desired) for configuration, but surprises people expecting live updates.- Ignoring
SIGTERMin favor of onlySIGINTworks for localCtrl-Ctesting but misses the signal most container orchestrators (Docker, Kubernetes) actually send on shutdown — production code should handleSIGTERMthe same way.
Cheat sheet¶
| Concern | Tool |
|---|---|
| Structured logs | swift-log's Logger + LoggingSystem.bootstrap |
| Environment-driven config | ProcessInfo.processInfo.environment[...] with defaults |
| Liveness/readiness signal | A HealthCheck type exposing a small enum status |
| Clean shutdown | DispatchSource.makeSignalSource for SIGTERM/SIGINT |
How It Actually Works¶
- Graceful shutdown handling relies on registering a signal handler
(
SIGTERM/SIGINT) that stops the SwiftNIO event loop group from accepting new connections while letting in-flight requests' tasks run to completion before the process actually exits — this is why a production server's shutdown path is asynchronous itself (draining, not an immediateexit()), and getting it wrong is a common source of dropped requests during deployments. - Environment-based configuration (reading
ProcessInfo.processInfo.environment) is resolved once at process start — the OS hands your process a full environment block atexec()time, and Swift'sProcessInfois a thin wrapper caching that snapshot, which is why environment variables changed after your process starts (e.g. via a sibling shell) are never observed without a restart. - Logging backends (
swift-log) use a protocol-witness-based "bootstrap once" pattern:LoggingSystem.bootstrap(...)installs a factory closure exactly once (subsequent calls are ignored, enforced via an atomic flag much likestatic let's one-time initialization), and everyLoggerinstance created afterward calls through to whatever concrete backend that factory produced — this indirection is what lets libraries log without knowing or caring which concrete logging backend the final application chooses. - Health checks and readiness probes typically hit a lightweight route handled entirely on the event loop without touching the database pool, specifically so that a probe never contends with real traffic for a pooled connection under load — a subtlety worth getting right so a health check doesn't itself become a bottleneck.
Exercise¶
Extend the HealthCheck type with a queueDepth: Int field and treat
queueDepth > 100 as degraded and queueDepth > 500 as unhealthy
(keeping the existing databaseReachable check as an immediate
unhealthy regardless of queue depth). Add a SIGTERM handler alongside
the existing SIGINT one that logs a distinct message before calling
exit(0), and verify both signals produce a graceful log line rather than
an abrupt process kill (you can send SIGTERM to your running process with
kill -TERM <pid> from another terminal to confirm).