Application Lifecycle and the Entrypoint
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Vapor 4.122.2 (the latest 4.x release, 2026-09-17) on Swift 6.4 in the Swift 6 language
mode, with vapor/template as the reference layout (swift-tools-version:6.3, macOS 13), swift-nio 2.104.0,
swift-log 1.16.1 and console-kit 4.x, checked on 2026-10-17. Vapor 5 is at 5.0.0-beta.4 and not yet released; its
differences are collected in Vapor 5 and swift-service-lifecycle and marked as upcoming. Every example was compiled
in a scratch package with the Swift 6 language mode and run for real, and the console output shown below is copied from
those runs.
A Vapor process is not "a server that happens to have a main`". It is a small state machine with a defined order:
decide the environment, set up logging, build the `Application, configure it, boot it, run one command (almost always
serve), and tear everything down again. Each step is an ordinary function call in entrypoint.swift, so the whole
lifecycle is visible, testable and replaceable. Getting the order right matters for correctness: a configure that
runs before logging is bootstrapped logs nothing, and shutdown work registered in the wrong place runs while requests
are still in flight (see The real shutdown order).
Where it runs in the request
Everything on this page happens around requests, never inside one. In the usual request lifecycle (the server accepts a connection and decodes the request, middleware wraps the router, the router matches a route, the handler runs, the response is encoded and written), the application lifecycle is the frame that exists before the first connection and after the last:
-
configure(_:)is where the middleware stack, the routes, the databases and every service are registered. After the server starts, that work is done: routes are looked up, not built. -
The
willBootanddidBoothandlers run once, before the server binds its port, so a handler can rely on them having completed before it receives its first request. -
shutdownhandlers and storage shutdown closures run after the process is told to stop, and, as shown below, they run while requests may still be in flight unless you arrange otherwise.
The phases at a glance
| Phase | API | What happens |
|---|---|---|
Detect environment |
|
Reads |
Bootstrap logging |
|
Reads |
Make |
|
Creates the |
Configure |
|
Your code: routes, middleware, lifecycle handlers, commands, databases, services. |
Boot |
|
Runs every registered handler’s |
Run a command |
|
Boots, then runs the command given on the command line, or the default |
Shut down |
|
Runs |
The standard entrypoint
The template’s entrypoint.swift is an @main enum. The version below is the template’s, with the optional executor
takeover removed (it has its own section) and comments naming each phase.
import Vapor
import Logging
@main
enum Entrypoint {
static func main() async throws {
// 1. Detect the environment from --env / -e, then VAPOR_ENV, then default to development.
var env = try Environment.detect()
// 2. Bootstrap swift-log once per process; --log and LOG_LEVEL are consumed here.
try LoggingSystem.bootstrap(from: &env)
// 3. Make the application (async factory: registers the "serve" command, loads .env files).
let app = try await Application.make(env)
do {
// 4. Configure: routes, middleware, lifecycle handlers, commands, services.
try await configure(app)
// 5. Boot (willBoot / didBoot), then run the command named on the command line.
// With no command this is "serve", which returns when SIGTERM or SIGINT arrives.
try await app.execute()
} catch {
app.logger.report(error: error)
try? await app.asyncShutdown()
throw error
}
// 6. Shut down: lifecycle handlers in reverse order, storage, thread pool.
try await app.asyncShutdown()
}
}
Source: vapor/template: entrypoint.swift
Why an @main enum and not main.swift: Top-level code in main.swift runs on the main actor in Swift 6, which makes it awkward to call the
async Application.make and to reason about isolation. An @main type with
static func main() async throws gives you an async entry point directly, and the compiler turns a thrown error into a
non-zero exit status.
Step 1: Environment.detect()
Environment is a Sendable struct with a name, the command-line arguments and, after detection, a commandInput.
detect() reads the arguments of the process and, using ConsoleKit, parses a single option:
| Input | Resulting environment |
|---|---|
|
|
|
|
|
|
any other name, such as |
a custom |
If --env is absent, the VAPOR_ENV process environment variable is used before falling back to development. This is
how containers choose the environment without changing the start command (the Configuration and Environment page builds on this). The official environment page documents the
flag but does not mention VAPOR_ENV; it is in the source of Environment.detect(from:) and works as described.
The remaining arguments are kept in env.commandInput, which is why swift run App serve --env production --port 9000
works: detect() consumes --env production, serve later parses --port 9000. A few facts worth knowing:
-
env.isReleaseis true in release builds and not derived from the name.Environment.production.isReleaseisfalsein a debug build. Useenv == .production(names compare equal) to branch on the deployment target andisReleaseonly for compile-mode decisions. -
Custom environments are plain values. Declare them next to the presets:
extension Environment { static var staging: Environment { .custom(name: "staging") } }Source: Vapor docs: Environment
-
Environment.get("KEY")and the dynamic-memberEnvironment.process.KEYread process variables. Secrets belong in such variables or a.envfile, never in source. -
Under Xcode and when
swift test --filterlaunches the xctest runner on macOS,detect()strips the extra arguments (user-defaults pairs starting with-NSor-Apple,-XCTestand a.xctestbundle path) that would otherwise make Vapor’s argument parsing fail.
Step 2: LoggingSystem.bootstrap(from:)
swift-log requires exactly one backend per process (the Logging page covers what to do with it afterwards). LoggingSystem.bootstrap(from: &env) is a Vapor extension that
takes the environment inout because it consumes the --log option from env.commandInput:
| Source | Effect |
|---|---|
|
sets the minimum level ( |
|
same, when |
neither |
|
It then installs a ConsoleKit ConsoleLogger on a Terminal for every label. To use another backend (for example a JSON
handler), call the factory overload bootstrap(from:_:), which keeps the level detection and replaces only the handler
factory, or call swift-log’s own LoggingSystem.bootstrap yourself.
swift-log documents that bootstrap may be called at most once per program and that a second call is undefined
behavior, most likely a crash. Two places tend to get this wrong: tests that bootstrap once per test, and entrypoints that
bootstrap a second backend. Bootstrap in main only, and in tests either skip it or run it behind a process-wide
static let.
Because logging is bootstrapped before Application.make, the application’s own logger (label
codes.vapor.application) and everything created from it already use the right backend. Reversing the two steps would
leave the first log lines on swift-log’s default handler.
Step 3: Application.make(_:)
make is static func make(_ environment: Environment = .development, _ eventLoopGroupProvider: EventLoopGroupProvider = .singleton) async throws -> Application.
There is a second overload that takes an explicit logger: (added in 4.116.0, useful when you bootstrap a custom
logger with metadata). What it does, in order:
-
creates the
Applicationand wires up the built-in services: the cache, view renderer, password hasher (bcrypt), sessions (memory), responder, HTTP server and HTTP client; -
registers the
routescommand and, as the default command,serve; -
loads the dotenv files (below);
-
returns the application. The
Applicationis afinal classthat isSendable; its mutable state sits behind locks.
The event loop group defaults to .singleton, which is MultiThreadedEventLoopGroup.singleton, one process-wide group
that Vapor never shuts down. The other case, .shared(group), lets you supply your own group (you then own its
lifetime). .createNew still exists but is deprecated: "Use '.singleton' for a shared 'EventLoopGroup', for better
performance".
Dotenv files. make loads .env.<environment name> first and .env second, from the working directory. Values already
present in the process environment are never overwritten, and a value in .env.development wins over the same key in
.env. A missing file is not an error: at debug level the log says Could not load .env file: Not found. Because the
working directory is the lookup root, running the binary from another directory or from Xcode (which defaults to a
DerivedData path) silently skips the files; set the working directory in the scheme or cd first.
Step 4: configure(_:)
configure is a convention, not a Vapor API: a function in your module that receives the Application. The template’s
version is func configure(_ app: Application) async throws and does little more than call routes(app). It is async
so you can await configuration that needs it (opening a database pool, loading a key). Typical contents, in the order
that rarely causes surprises:
-
logging/metrics hooks that need the application;
-
storage values and services (
app.storage, databases, queues, JWT keys); -
middleware (
app.middleware), because the order is the request order; -
lifecycle handlers and custom commands;
-
server settings (
app.http.server.configuration); -
routes last.
The complete configure.swift used for all the output on this page follows. It registers a logging lifecycle handler,
an actor stored in app.storage with an async shutdown closure, a custom seed command, a drain-first handler
(explained under The real shutdown order) and a shorter shutdown timeout.
import Vapor
/// Observes the application's lifecycle and logs each phase.
struct LifecycleLogger: LifecycleHandler {
func willBootAsync(_ application: Application) async throws {
application.logger.info("lifecycle: willBoot")
}
func didBootAsync(_ application: Application) async throws {
application.logger.info("lifecycle: didBoot")
}
func shutdownAsync(_ application: Application) async {
application.logger.info("lifecycle: shutdown")
}
}
/// Drains the HTTP server before any other shutdown work runs.
///
/// Handlers shut down in reverse registration order, so register this one last.
struct DrainServerFirst: LifecycleHandler {
func shutdownAsync(_ application: Application) async {
await application.http.server.shared.shutdown()
}
}
/// A resource that must be closed on shutdown, such as a client or a connection pool.
actor ShelfCache {
private var entries: [String: String] = [:]
func put(_ key: String, _ value: String) { entries[key] = value }
func close() { entries.removeAll() }
}
struct ShelfCacheKey: StorageKey {
typealias Value = ShelfCache
}
extension Application {
var shelfCache: ShelfCache {
guard let cache = storage[ShelfCacheKey.self] else {
fatalError("ShelfCache not configured; call configure(_:) first")
}
return cache
}
}
/// A custom command, run with `swift run App seed`.
struct SeedCommand: AsyncCommand {
struct Signature: CommandSignature {
@Option(name: "count", short: "c", help: "How many books to seed")
var count: Int?
}
var help: String { "Seeds the Bookshelf cache with sample books." }
func run(using context: CommandContext, signature: Signature) async throws {
let count = signature.count ?? 3
for index in 1...count {
await context.application.shelfCache.put("book-\(index)", "Title \(index)")
}
context.console.success("Seeded \(count) books")
}
}
func configure(_ app: Application) async throws {
app.logger.info("configure: environment is \(app.environment.name)")
await app.storage.setWithAsyncShutdown(ShelfCacheKey.self, to: ShelfCache()) { cache in
await cache.close()
app.logger.info("storage: ShelfCache closed")
}
app.lifecycle.use(LifecycleLogger())
app.lifecycle.use(DrainServerFirst())
app.asyncCommands.use(SeedCommand(), as: "seed")
app.http.server.configuration.shutdownTimeout = .seconds(5)
try routes(app)
}
And the routes it registers (/stuck exists only to demonstrate the shutdown timeout):
import Vapor
func routes(_ app: Application) throws {
app.get("hello") { req async -> String in "Hello, world!" }
app.get("stuck") { req async throws -> String in
try await Task.sleep(for: .seconds(30))
return "never"
}
app.get("slow") { req async throws -> String in
try await Task.sleep(for: .seconds(2))
return "done"
}
}
Source: Vapor docs: Routing
Step 5: app.execute()
execute() is the async replacement of run() (added in 4.89.0). It does three things:
-
startup(), which callsasyncBoot()and then builds the command group (mergingapp.asyncCommandsand the legacyapp.commands) and runs the command named on the command line, using the default (serve) when there is none; -
if a server was started, awaits
app.running?.onStop, a promise that theservecommand fulfills on SIGTERM or SIGINT; -
logs and rethrows any error.
So for serve, execute() returns only when a termination signal arrives. For every other command (routes, seed)
it returns as soon as the command finishes. startup() is public too: call it directly when you want to start the
server and keep control of the process yourself, for example in an app that hosts other work next to Vapor.
Step 6: app.asyncShutdown()
asyncShutdown() is mandatory: Application.deinit logs Application.shutdown() was not called before Application
deinitialized. as an error and falls back to the synchronous shutdown(), which is noasync and can block. It asserts
(debug builds) that shutdown has not already happened, so call it exactly once. In order, it:
-
calls
shutdownAsyncon every lifecycle handler, in reverse registration order, and then forgets the handlers; -
shuts down every value in
app.storagethat has a shutdown closure, and clears the storage; theservecommand and the HTTP server are such values; -
shuts down the event loop group only when it was created by the application (
.createNew); the singleton group is left alone; -
marks the application as shut down.
The template calls it in both the success and the error path; the try? in the catch block keeps the original error
instead of replacing it with a shutdown error.
Lifecycle handlers
LifecycleHandler is the supported extension point for work at boot and shutdown. It is a Sendable protocol; every
method has a default implementation, so a handler implements only what it needs.
| Method | When | Notes |
|---|---|---|
|
after |
Prepare resources; a thrown error aborts startup. |
|
after every handler’s |
Everything configured and prepared exists; the server has not bound yet. |
|
during |
Cannot throw; log and move on. |
|
legacy |
Used by the async variants' default implementations and by the synchronous |
The async methods' default implementations forward to the synchronous ones, so a legacy handler that only implements
shutdown(_:) still works under Application.make. The reverse is not true: if you call the deprecated synchronous
Application(env) path, only the synchronous methods are invoked. The async variants arrived in 4.100.0 because
packages such as Redis called .wait() inside shutdown, which can crash under the NIO global executor.
Behaviours that follow from the source:
-
Order. All
willBootcalls run in registration order, then alldidBootcalls in registration order.shutdownruns in the reverse registration order, like a stack. A handler registered by a package (Fluent, see Fluent Overview and Database Drivers; Queues) is ordered relative to yours by when you call itsconfigure-time setup. -
The core handler. Vapor registers its own handler in
Application.makethat stops the blocking thread pool. Being registered first, it shuts down last among handlers. -
Boot is idempotent.
asyncBoot()returns immediately when already booted;routes,serveand tests can all call it. In 4.122.2 the flag is set before the handlers run, so a handler that throws leaves the application marked as booted and a retry does nothing. Treat a failed boot as fatal (the template does). -
Handlers are
Sendable. Under Swift 6 a handler is a struct or a final class withSendablestored properties. Capture theApplicationthrough the method parameter, not by storing it. -
Because
didBootruns before the port is bound, do not use it to wait for the server. To know the server is up, log afterexecute()starts the command, or poll the server’slocalAddress.
A complete example, registering a handler and printing what runs when. This is the LifecycleLogger from
configure.swift above, so its output appears in the run logs below.
struct LifecycleLogger: LifecycleHandler {
func willBootAsync(_ application: Application) async throws {
application.logger.info("lifecycle: willBoot")
}
func didBootAsync(_ application: Application) async throws {
application.logger.info("lifecycle: didBoot")
}
func shutdownAsync(_ application: Application) async {
application.logger.info("lifecycle: shutdown")
}
}
// in configure(_:)
app.lifecycle.use(LifecycleLogger())
Source: Vapor docs: Services, Lifecycle
For a single value that needs cleanup, app.storage is usually simpler than a handler. The configure.swift above
stores an actor under a StorageKey and passes the cleanup closure:
await app.storage.setWithAsyncShutdown(ShelfCacheKey.self, to: ShelfCache()) { cache in
await cache.close()
}
The value type must be Sendable (StorageKey.Value: Sendable), which is why a shared mutable cache is an actor.
The synchronous app.storage.set(_:to:onShutdown:) is noasync and cannot be called from configure(_:); use
setWithAsyncShutdown.
The commands system at startup
After booting, execute() runs one command. Vapor’s commands are ConsoleKit AsyncCommand types registered on
app.asyncCommands (the older app.commands still works for the synchronous Command protocol). With no arguments the
default command, serve, runs. The names after the executable choose another one:
| Command | Behaviour |
|---|---|
|
Starts the HTTP server and waits for SIGTERM or SIGINT. Options: |
|
Prints an ASCII table of the registered routes and returns. |
|
Added by Fluent ( |
|
Defined in the source as a command that prints |
custom |
Anything you register with |
Run swift run App --help to list what is registered, and swift run App <command> --help for a command’s options. The
output with the configure.swift above:
$ swift run App --help
Usage: App <command>
Commands:
routes Displays all registered routes.
seed Seeds the Bookshelf cache with sample books.
serve Begins serving the app over HTTP.
$ swift run App routes
+-----+--------+
| GET | /hello |
+-----+--------+
| GET | /stuck |
+-----+--------+
| GET | /slow |
+-----+--------+
$ swift run App seed --count 2
Seeded 2 books
Every command, including routes and seed, goes through the whole lifecycle: configure ran, the handlers'
willBoot and didBoot ran, and shutdown runs afterward (the log lines lifecycle: willBoot, didBoot and
shutdown appear around Seeded 2 books). That is why a custom command can use app.db, app.storage and every
service the server would, and why a slow didBoot also slows routes.
The boot command is not registered in 4.122.2. BootCommand exists in the source and the older documentation mentions
a boot command, but Application.Core.Storage.init creates a local AsyncCommands, registers BootCommand on it and
then assigns a fresh, empty AsyncCommands() to the property. Running it confirms:
$ swift run App boot
[ WARNING ] .unknownCommand("boot", available: ["seed", "routes", "serve"])
Swift/ErrorType.swift:254: Fatal error: Error raised at top level: .unknownCommand("boot", available: ["seed", "routes", "serve"])
To check "does the application configure and boot?" in CI use swift run App routes, or register your own command. This
is a statement about the checked version, not a documented behavior; re-test on upgrade.
The custom command in configure.swift, in isolation:
struct SeedCommand: AsyncCommand {
struct Signature: CommandSignature {
@Option(name: "count", short: "c", help: "How many books to seed")
var count: Int?
}
var help: String { "Seeds the Bookshelf cache with sample books." }
func run(using context: CommandContext, signature: Signature) async throws {
let count = signature.count ?? 3
for index in 1...count {
await context.application.shelfCache.put("book-\(index)", "Title \(index)")
}
context.console.success("Seeded \(count) books")
}
}
app.asyncCommands.use(SeedCommand(), as: "seed")
Source: Vapor docs: Commands
@Option and @Argument are ConsoleKit property wrappers, which is why Signature structs hold var optionals. If
count were 0 or negative the range 1...count would trap at runtime; validate input before using it.
The real shutdown order
ServeCommand installs DispatchSource signal sources for SIGTERM and SIGINT (on Darwin it first sets SIG_IGN for
the signal, so the default handler does not kill the process). When either arrives it prints a newline (to clear ^C) and
completes the running promise, which makes execute() return. Nothing is shut down yet: the entrypoint then calls
asyncShutdown(), and that is where the server stops. Further signals while draining are ignored; verified by sending
SIGTERM twice and SIGINT once during a request.
The surprising part, confirmed with a real run (debug log, one /slow request in flight, SIGTERM sent with
configure.swift without the DrainServerFirst handler):
[ INFO ] GET /slow [request-id: ...]
[ DEBUG ] Application shutting down
[ INFO ] lifecycle: shutdown
[ INFO ] storage: ShelfCache closed
[ DEBUG ] Requesting HTTP server shutdown
[ DEBUG ] HTTP server shutting down
The HTTP server is stopped after the lifecycle handlers and after another storage value was closed. The server is
shut down by a closure stored in app.storage, and Storage.asyncShutdown() iterates a dictionary, so the relative
order of storage values is unspecified. In this run the cache closed while the /slow request was still running. A
shutdownAsync handler that closes a database pool therefore runs while requests may still be using it.
The remedy is to make the server drain first. A lifecycle handler registered last shuts down first, so:
struct DrainServerFirst: LifecycleHandler {
func shutdownAsync(_ application: Application) async {
await application.http.server.shared.shutdown()
}
}
// in configure(_:), after every other app.lifecycle.use(...)
app.lifecycle.use(DrainServerFirst())
HTTPServer.shutdown() is safe to call twice: it returns at once when the connection is already gone, so the later call
from the serve command’s storage closure is a no-op. With the handler in place, the same run logs:
[ INFO ] GET /slow [request-id: ...]
[ DEBUG ] Application shutting down
[ DEBUG ] Requesting HTTP server shutdown
[ DEBUG ] HTTP server shutting down
[ INFO ] lifecycle: shutdown
[ INFO ] storage: ShelfCache closed
and the /slow client receives done with status 200. The process exit status was 0.
The shutdown timeout. HTTPServer.Configuration.shutdownTimeout is "a time limit to complete a graceful shutdown"
and defaults to 10 seconds. configure.swift lowers it to 5 seconds with
app.http.server.configuration.shutdownTimeout = .seconds(5). Calling /stuck (a 30 second handler) and sending SIGTERM:
[ DEBUG ] Requesting HTTP server shutdown
[ ERROR ] Could not stop HTTP server: Abort.500: Server stop took too long.
[ DEBUG ] HTTP server shutting down
[ INFO ] lifecycle: shutdown
[ INFO ] storage: ShelfCache closed
The process exited after 5 seconds and the client’s connection ended without a response. Keep the timeout shorter than
your orchestrator’s grace period (Kubernetes terminationGracePeriodSeconds defaults to 30 seconds, docker stop to 10),
otherwise the orchestrator sends SIGKILL first and the cleanup after the drain never runs. Vapor offers no hook for
"stop accepting but keep serving for N seconds" (a readiness-drain delay) or for ending WebSockets and streams; do that in
your own handler, for example by flipping a readiness flag in a route before calling shutdown(). The drain handler and the
shutdown settings in production are developed on Production and Performance. The equivalent
walk-through for another framework is on Graceful Shutdown (axum), and the
orchestrator side is on Health Probes and Lifecycle.
Docker and PID 1. Signals reach the process only when it is the container’s PID 1 started in exec form. A shell-form
CMD or ENTRYPOINT makes sh PID 1; sh does not forward SIGTERM, so the app is killed after the grace period with
requests dropped. Use the exec form, as below (see Deployment with Docker for the whole image). The server binds to 127.0.0.1 by default, so inside a container it must be told --hostname 0.0.0.0 or it is
unreachable.
ENTRYPOINT ["./App"]
CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]
Source: Vapor docs: Docker
The template’s Dockerfile writes ENTRYPOINT ["./{{name}}"], which the Toolbox replaces with the project name;
./App is the executable target name this section uses by convention.
Running the application for tests and one-off tasks
Tests and scripts need a booted application without a server. The pattern is the entrypoint without execute():
func testingApp() async throws {
let app = try await Application.make(.testing)
do {
try await configure(app)
try await app.asyncBoot()
} catch {
try await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
Source: Vapor docs: Testing
VaporTesting wraps exactly this as withApp and app.testing().test(...). Environment.testing sanitizes the
arguments by itself, so it can be used directly, without detect(). Always pair make with asyncShutdown(), even
on failure, or the deinitializer logs the error shown under Common errors.
The optional NIO global executor
By default Swift Concurrency runs async code on its own cooperative thread pool, while Vapor’s I/O runs on NIO event
loops, so a handler that awaits a database call hops between the two. SwiftNIO can install its singleton event loop
group as the global executor, which removes many of those hops:
import NIOCore
import NIOPosix
// in main(), before any other async work
let installed = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
app.logger.debug("Tried to install the NIO executor", metadata: ["success": .stringConvertible(installed)])
Source: vapor/template: entrypoint.swift (the call is commented out there)
It is commented out in the template for good reasons, all visible in the swift-nio source (2.104.0) and the template’s own comment:
-
it relies on a private runtime hook (
swift_task_enqueueGlobal_hook) found withdlsym; the function returnsfalsewhen the hook is missing andtrueon success. On this setup (Swift 6.4, macOS) it returnedtrue, and aservewith a request in flight shut down cleanly; -
it must be called once and, on Darwin, from the main thread (a second call or a call off the main thread is a
fatalError); on Windows it returnsfalse; -
the template warns that it "has caused issues with some libraries that use
.wait()and cleanly shutting down" and that async functions called before it can trigger assertion failures; call it first inmain; -
CPU-bound or blocking work then runs on event loop threads, which stalls I/O. Offload it to
app.threadPool(see the Swift Concurrency in Vapor page).
Measure before enabling it; the benefit is fewer thread switches in I/O-heavy handlers, not faster code.
Vapor 5 and swift-service-lifecycle
Vapor 5 is not released (5.0.0-beta.4, 2026-10-13; it needs Swift 6.4 and macOS 26.2); everything here is read from the
beta’s source and may change before the release. The lifecycle moves onto swift-service-lifecycle
(ServiceLifecycle), the server-side Swift library that runs
several long-lived Service values together and shuts them down on signals:
-
Applicationconforms toServiceLifecycle.Serviceand is created with a synchronous throwinginit(theApplication.makefactory and theEventLoopGroupProviderdisappear). -
app.start()runs the application as a standalone process: it builds aServiceGroupthat handlesSIGTERMandSIGINTand blocks until a signal arrives.app.run()runs it as aServicewithout signal handling, for embedding in your ownServiceGroup, which is the supported way to run Vapor next to other services (a queue worker, a metrics exporter; see Tracing, Metrics & OpenTelemetry for swift-otel running next to Vapor). -
Extra services are registered with
app.addService(_:), lifecycle handlers withapp.addLifecycleHandler(_:). -
An explicit
Application.State(configuring,booting,booted,starting,started,shuttingDown,shutdown) guards the transitions, configuration is frozen once the server starts, andboot()after a failed boot returns toconfiguringso a retry works. Calling an operation in the wrong state throwsApplication.LifecycleError. -
Configuration is read through swift-configuration: the
Applicationinitializer takes aConfigReader(command-line arguments and environment variables by default) andEnvironment.detect(from:)takes that reader.
For Vapor 4 there is no supported way to hand control to a ServiceGroup: Vapor installs its own signal sources, so adding
a group with signal handling on top would double-handle SIGTERM. If you need other long-lived tasks next to a Vapor 4
server, start them from a didBoot handler, cancel them in shutdown, and keep them within the shutdown window. The
upgrade is covered on What’s New, Upgrading and Vapor 5.
Deprecated forms and history
The old API is still compiled but marked @available(*, deprecated) and/or @available(*, noasync). noasync is the
stronger one: using the symbol from an async function is a compile error, not a warning. Compiling the old calls from an
async context in the scratch package gives:
error: initializer 'init' is unavailable from asynchronous contexts; This initialiser cannot be used in async contexts, use Application.make(_:_:) instead
error: instance method 'run' is unavailable from asynchronous contexts; Use the async execute() method instead.
error: instance method 'shutdown' is unavailable from asynchronous contexts; This can block the thread and should not be called in an async context
error: instance method 'boot' is unavailable from asynchronous contexts; This can potentially block the thread and should not be called in an async context
| Old form | Replacement | Changed in |
|---|---|---|
|
|
|
|
|
|
|
|
4.89.0 |
|
|
|
|
|
|
|
|
async variants in 4.100.0 |
|
|
|
|
|
deprecated |
|
|
template change |
|
|
template change |
Version numbers come from the Vapor release notes (GitHub releases); the
noasync annotations from Application.swift in 4.122.2.
Earlier generations of Vapor looked different:
-
Vapor 2 (Patel’s book) built a
Dropletinmain.swift, configured it with JSON files underConfig/, wired packages in throughProvidertypes, and calleddrop.run(). -
Vapor 3 (Yeung, Condon) had
app.swiftwithfunc app(_ env: Environment) throws -> Application, aconfigure.swifttakinginout Services, aboot.swiftfor post-configuration, and aRuntarget. Dependency injection was theServicescontainer (services.register(...),config.prefer(...),req.make(...)). Vapor 4 replaced it with typed properties onApplicationandRequest,app.storageandStorageKey, andapp.lifecycle. -
Vapor 4.0 to 4.88 used
Application(env)andapp.run()in a synchronousmain.swift, withdefer { app.shutdown() }.
Swift 6 concurrency notes
-
Applicationis afinal classthat isSendable. Pass it across tasks freely; do not add storedvar`s to it through extensions, store values with `app.storageand aStorageKeywhoseValueisSendable. -
LifecycleHandlerinheritsSendable, so handlers are value types orSendablefinal classes; the async methods are nonisolated. State shared betweenwillBootandshutdownbelongs in an actor or inapp.storage. -
app.loggeris a computed property behind a lock and returns a copy of theLoggerstruct; assign a whole new value (app.logger = newLogger) rather than mutating a copy you keep elsewhere. -
Storage shutdown closures are
@Sendable. Capturingappinside one (asconfigure.swiftdoes to log) is allowed becauseApplicationisSendable; capturing a non-Sendabletype is a compile error in Swift 6 mode. -
The template enables upcoming features (
ExistentialAny,InternalImportsByDefault,MemberImportVisibility,InferIsolatedConformances,ImmutableWeakCaptures). WithInternalImportsByDefaultandMemberImportVisibility, a file that usesNIOSingletonsneeds its ownimport NIOCoreandimport NIOPosix; importing onlyVaporis not enough. -
Blocking calls, such as
.wait()on anEventLoopFuture, are never allowed inmainonce it isasync, and are fatal on an event loop. See the Swift Reference pages Async/Await and Tasks and Actors, Isolation and Sendable.
Common errors
| Symptom | Cause and fix |
|---|---|
|
A |
|
A path out of |
Assertion |
|
|
There is no |
|
|
|
Another process holds the port. Change |
|
A request outlived |
|
|
|
Changing address, backlog, |
|
|
Config values missing in Xcode |
|
No logs in production |
Default level is |
Shutdown ends but requests were cut off |
The lifecycle handlers ran before the server drained; use the drain-first handler. |
Server unreachable in a container |
Default bind is |
Version notes
| Version | Change |
|---|---|
4.0 |
|
4.89.0 |
Fully async entrypoints: |
4.99.0 |
|
4.100.0 |
Async lifecycle handler methods |
4.110.0 |
|
4.113.0 |
|
4.116.0 |
|
4.122.2 |
current 4.x; |
5.0.0-beta.4 |
|
Where the books are outdated
Patel (Server-Side Swift with Vapor, Packt, chapters 3 and 4) builds a Vapor 2 Droplet from Config/*.json and
Provider`s; none of it exists in Vapor 4. Yeung and the Kodeco book (Condon et al.) show the Vapor 3 `app.swift,
configure(_ config: inout Config, _ env: inout Environment, _ services: inout Services) and boot.swift, and
Application(env)/app.run() for Vapor 4’s first releases. Today: @main enum Entrypoint, Application.make,
execute() and asyncShutdown(). None of the books covers LifecycleHandler async variants, the shutdown ordering
caveat above, graceful shutdown timeouts or the NIO global executor.
References
-
Vapor docs: Folder structure and vapor/template: entrypoint.swift
-
vapor/vapor 4.122.2: Application.swift, LifecycleHandler.swift, ServeCommand.swift and HTTPServer.swift
-
vapor/vapor releases (4.89.0, 4.99.0, 4.100.0, 4.113.0, 4.116.0) and vapor/vapor 5.0.0-beta.4
-
swift-log documentation and swift-nio: concurrency take-over
-
Kubernetes: Termination of Pods and Dockerfile: shell and exec form