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 willBoot and didBoot handlers run once, before the server binds its port, so a handler can rely on them having completed before it receives its first request.

  • shutdown handlers 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

stateDiagram-v2 [*] --> DetectEnvironment DetectEnvironment: Environment.detect() DetectEnvironment --> BootstrapLogging BootstrapLogging: LoggingSystem.bootstrap(from:) BootstrapLogging --> Make Make: Application.make(env) Make --> Configure Configure: configure(app) Configure --> Boot Boot: asyncBoot() runs willBoot then didBoot Boot --> Serve: no command or serve Boot --> OtherCommand: routes or a custom command Serve: serve command listens until SIGTERM or SIGINT OtherCommand: run the command, then return Serve --> Shutdown: signal received OtherCommand --> Shutdown: command finished Boot --> Shutdown: handler or command threw Configure --> Shutdown: configure threw Shutdown: asyncShutdown() reverse handlers, storage, thread pool Shutdown --> [*]
Phase API What happens

Detect environment

Environment.detect()

Reads --env/-e, then VAPOR_ENV, then falls back to development. The remaining arguments stay in env.commandInput for the command to parse.

Bootstrap logging

LoggingSystem.bootstrap(from: &env)

Reads --log and LOG_LEVEL, installs a console log handler for the whole process. Once per process.

Make

try await Application.make(env)

Creates the Application, registers the built-in services and the serve and routes commands, loads the .env files.

Configure

try await configure(app)

Your code: routes, middleware, lifecycle handlers, commands, databases, services.

Boot

app.asyncBoot() (called by execute())

Runs every registered handler’s willBoot, then every handler’s didBoot, once.

Run a command

try await app.execute()

Boots, then runs the command given on the command line, or the default serve command.

Shut down

try await app.asyncShutdown()

Runs shutdown handlers in reverse order, shuts down storage values, stops the thread pool.

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()
    }
}

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

--env prod or --env production (short form -e)

.production

--env dev, --env development, or nothing at all

.development

--env test, --env testing

.testing

any other name, such as --env staging

a custom Environment(name: "staging")

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.isRelease is true in release builds and not derived from the name. Environment.production.isRelease is false in a debug build. Use env == .production (names compare equal) to branch on the deployment target and isRelease only for compile-mode decisions.

  • Custom environments are plain values. Declare them next to the presets:

    extension Environment {
        static var staging: Environment { .custom(name: "staging") }
    }
  • Environment.get("KEY") and the dynamic-member Environment.process.KEY read process variables. Secrets belong in such variables or a .env file, never in source.

  • Under Xcode and when swift test --filter launches the xctest runner on macOS, detect() strips the extra arguments (user-defaults pairs starting with -NS or -Apple, -XCTest and a .xctest bundle 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

--log debug

sets the minimum level (trace, debug, info, notice, warning, error, critical)

LOG_LEVEL=debug

same, when --log is absent

neither

notice in .production, info otherwise

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:

  1. creates the Application and wires up the built-in services: the cache, view renderer, password hasher (bcrypt), sessions (memory), responder, HTTP server and HTTP client;

  2. registers the routes command and, as the default command, serve;

  3. loads the dotenv files (below);

  4. returns the application. The Application is a final class that is Sendable; 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:

  1. logging/metrics hooks that need the application;

  2. storage values and services (app.storage, databases, queues, JWT keys);

  3. middleware (app.middleware), because the order is the request order;

  4. lifecycle handlers and custom commands;

  5. server settings (app.http.server.configuration);

  6. 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"
    }
}

Step 5: app.execute()

execute() is the async replacement of run() (added in 4.89.0). It does three things:

  1. startup(), which calls asyncBoot() and then builds the command group (merging app.asyncCommands and the legacy app.commands) and runs the command named on the command line, using the default (serve) when there is none;

  2. if a server was started, awaits app.running?.onStop, a promise that the serve command fulfills on SIGTERM or SIGINT;

  3. 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:

  1. calls shutdownAsync on every lifecycle handler, in reverse registration order, and then forgets the handlers;

  2. shuts down every value in app.storage that has a shutdown closure, and clears the storage; the serve command and the HTTP server are such values;

  3. shuts down the event loop group only when it was created by the application (.createNew); the singleton group is left alone;

  4. 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

willBootAsync(_:) async throws

after configure, before any didBoot

Prepare resources; a thrown error aborts startup.

didBootAsync(_:) async throws

after every handler’s willBoot returned

Everything configured and prepared exists; the server has not bound yet.

shutdownAsync(_:) async

during asyncShutdown(), reverse order

Cannot throw; log and move on.

willBoot, didBoot, shutdown (sync)

legacy

Used by the async variants' default implementations and by the synchronous boot()/shutdown().

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 willBoot calls run in registration order, then all didBoot calls in registration order. shutdown runs 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 its configure-time setup.

  • The core handler. Vapor registers its own handler in Application.make that stops the blocking thread pool. Being registered first, it shuts down last among handlers.

  • Boot is idempotent. asyncBoot() returns immediately when already booted; routes, serve and 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 with Sendable stored properties. Capture the Application through the method parameter, not by storing it.

  • Because didBoot runs before the port is bound, do not use it to wait for the server. To know the server is up, log after execute() starts the command, or poll the server’s localAddress.

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())

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

serve (default)

Starts the HTTP server and waits for SIGTERM or SIGINT. Options: --hostname/-H, --port/-p, --bind/-b (host:port), --unix-socket. The default address is 127.0.0.1:8080. Mixing --unix-socket with the others throws ServeCommand.Error.incompatibleFlags.

routes

Prints an ASCII table of the registered routes and returns.

migrate

Added by Fluent (app.migrations/app.databases); not present without it. Custom commands are covered on the Commands page.

boot

Defined in the source as a command that prints Done., but see below.

custom

Anything you register with app.asyncCommands.use(_:as:).

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")

@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.

sequenceDiagram participant K as Orchestrator or terminal participant E as Entrypoint participant S as ServeCommand participant A as Application participant H as HTTPServer K->>S: SIGTERM or SIGINT S->>E: running promise completed, execute() returns E->>A: asyncShutdown() A->>A: LifecycleHandler.shutdownAsync (reverse order) A->>A: storage values shut down (unspecified order) A->>H: ServeCommand storage closure calls server.shutdown() H->>H: stop accepting, drain until shutdownTimeout H-->>A: closed A->>A: stop thread pool, mark didShutdown A-->>E: asyncShutdown() returns E-->>K: main returns, exit status 0

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"]

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()
}

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 with dlsym; the function returns false when the hook is missing and true on success. On this setup (Swift 6.4, macOS) it returned true, and a serve with 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 returns false;

  • 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 in main;

  • 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:

  • Application conforms to ServiceLifecycle.Service and is created with a synchronous throwing init (the Application.make factory and the EventLoopGroupProvider disappear).

  • app.start() runs the application as a standalone process: it builds a ServiceGroup that handles SIGTERM and SIGINT and blocks until a signal arrives. app.run() runs it as a Service without signal handling, for embedding in your own ServiceGroup, 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 with app.addLifecycleHandler(_:).

  • An explicit Application.State (configuring, booting, booted, starting, started, shuttingDown, shutdown) guards the transitions, configuration is frozen once the server starts, and boot() after a failed boot returns to configuring so a retry works. Calling an operation in the wrong state throws Application.LifecycleError.

  • Configuration is read through swift-configuration: the Application initializer takes a ConfigReader (command-line arguments and environment variables by default) and Environment.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

let app = Application(env)

let app = try await Application.make(env)

init deprecated in 4.113.0

try app.run()

try await app.execute()

execute() added in 4.89.0

try app.start()

try await app.startup()

4.89.0

app.shutdown()

try await app.asyncShutdown()

asyncShutdown() added in 4.99.0

try app.boot()

try await app.asyncBoot()

boot() is noasync and renamed

LifecycleHandler.willBoot/didBoot/shutdown

willBootAsync/didBootAsync/shutdownAsync

async variants in 4.100.0

app.storage.set(_:to:onShutdown:)

await app.storage.setWithAsyncShutdown(_:to:onShutdown:)

noasync; keeps working outside async code

EventLoopGroupProvider.createNew

.singleton

deprecated

main.swift + try app(.detect()).run()

@main enum Entrypoint

template change

Run executable target

App executable target (swift run App serve)

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 Droplet in main.swift, configured it with JSON files under Config/, wired packages in through Provider types, and called drop.run().

  • Vapor 3 (Yeung, Condon) had app.swift with func app(_ env: Environment) throws -> Application, a configure.swift taking inout Services, a boot.swift for post-configuration, and a Run target. Dependency injection was the Services container (services.register(...), config.prefer(...), req.make(...)). Vapor 4 replaced it with typed properties on Application and Request, app.storage and StorageKey, and app.lifecycle.

  • Vapor 4.0 to 4.88 used Application(env) and app.run() in a synchronous main.swift, with defer { app.shutdown() }.

Swift 6 concurrency notes

  • Application is a final class that is Sendable. Pass it across tasks freely; do not add stored var`s to it through extensions, store values with `app.storage and a StorageKey whose Value is Sendable.

  • LifecycleHandler inherits Sendable, so handlers are value types or Sendable final classes; the async methods are nonisolated. State shared between willBoot and shutdown belongs in an actor or in app.storage.

  • app.logger is a computed property behind a lock and returns a copy of the Logger struct; assign a whole new value (app.logger = newLogger) rather than mutating a copy you keep elsewhere.

  • Storage shutdown closures are @Sendable. Capturing app inside one (as configure.swift does to log) is allowed because Application is Sendable; capturing a non-Sendable type is a compile error in Swift 6 mode.

  • The template enables upcoming features (ExistentialAny, InternalImportsByDefault, MemberImportVisibility, InferIsolatedConformances, ImmutableWeakCaptures). With InternalImportsByDefault and MemberImportVisibility, a file that uses NIOSingletons needs its own import NIOCore and import NIOPosix; importing only Vapor is not enough.

  • Blocking calls, such as .wait() on an EventLoopFuture, are never allowed in main once it is async, 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

initializer 'init' is unavailable from asynchronous contexts (or run, shutdown, boot)

A noasync method called from async code. Use Application.make, execute(), asyncShutdown(), asyncBoot().

Application.shutdown() was not called before Application deinitialized. (error log)

A path out of main skipped asyncShutdown(). Call it on the success path and in catch.

Assertion Application has already shut down

asyncShutdown() called twice. Keep one call per path.

.unknownCommand("boot", ...)

There is no boot command in 4.122.2; see the commands section.

Vapor.ServeCommand.Error.incompatibleFlags

--unix-socket combined with --hostname, --port or --bind. Use one style.

bind(descriptor:ptr:bytes:): Address already in use) (errno: 48)

Another process holds the port. Change --port or stop the other process. The error is raised after the lifecycle handlers booted, so shutdown handlers still run, as in the log.

Could not stop HTTP server: Abort.500: Server stop took too long.

A request outlived shutdownTimeout. Shorten handlers, cancel long work on shutdown, or raise the timeout (within the orchestrator’s grace period).

Cannot replace thread pool after application has booted (fatal)

app.threadPool = newPool assigned after boot; set it in configure.

Cannot modify server configuration after server has been started. (warning)

Changing address, backlog, reuseAddress or tcpNoDelay after the server started; the change is discarded.

logging system can only be initialized once per process (fatal)

LoggingSystem.bootstrap called twice, typically in tests.

Config values missing in Xcode

.env is read from the working directory; set it in the scheme.

No logs in production

Default level is notice there: info lines such as GET /hello are hidden. Use --log info or LOG_LEVEL=info.

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 127.0.0.1; pass --hostname 0.0.0.0.

Version notes

Version Change

4.0

Application, app.lifecycle, LifecycleHandler, app.storage; synchronous main.swift entrypoint (Droplet and Services of earlier majors gone)

4.89.0

Fully async entrypoints: execute() and startup()

4.99.0

asyncShutdown(); the synchronous shutdown() becomes noasync

4.100.0

Async lifecycle handler methods

4.110.0

VaporTesting module (Swift Testing)

4.113.0

Application.init deprecated in favor of Application.make

4.116.0

Application.make(_:_:logger:)

4.122.2

current 4.x; boot command not registered, handlers shut down before the HTTP server unless drained first

5.0.0-beta.4

Application is a Service; start()/run(); explicit lifecycle state; configuration via swift-configuration (upcoming, unreleased)

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.