Getting Started

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 on Swift 6.4 (the package builds in the Swift 6 language mode; the official template declares swift-tools-version:6.3, Vapor 4.122.2 itself needs tools version 6.0), with the Vapor Toolbox 20.0.2 and swift-nio 2.104.0, checked on 2026-10-17. It creates the bookshelf project that the rest of this section extends. Every Swift listing on this page was compiled with Swift 6.4 in the Swift 6 language mode with zero warnings, and the curl and command output was captured from that build running on macOS. Vapor 5 is at 5.0.0-beta.4 and is not released yet; where it matters, differences are noted. If you have not installed Swift and the Toolbox yet, do that first (the Installation and Toolchain page covers macOS, Linux, Swiftly and the editors; the Introduction & Architecture page explains what Vapor is made of). Swift itself is covered by the Swift Reference, and SwiftPM by Swift Package Manager.

The Toolbox 20 only creates projects (vapor new); building and running use swift build and swift run. The Toolbox prompts below are therefore quoted from the official documentation and the Toolbox README, while the build, curl and command output comes from the official vapor/template without Fluent and Leaf (what you get when you answer "no" twice), plus the routes of this page.

Where it runs in the request

In the request lifecycle (SwiftNIO accepts the connection and parses HTTP, Vapor’s responder chain runs the middleware, the router matches a route, the handler runs, and the returned value is encoded into a response), this page touches the essential steps once. swift run App serve starts the HTTP server, the Router matches GET /hello or GET /books/:id, the closure handler runs, and its return value (a String or a Content struct) is encoded as a text/plain or application/json response. Only Vapor’s default middleware (the route logger that logs each request, then the error middleware that turns an Abort into a JSON body) is involved. Custom middleware (Middleware), Content validation (Validation) and databases (Fluent Overview and Database Drivers) arrive on later pages.

flowchart LR A["vapor new bookshelf"] --> B["swift build"] B --> C["swift run App serve"] C --> D["HTTP server on 127.0.0.1:8080"] D --> E["Router matches GET /books/:id"] E --> F["Handler returns a Content struct"] F --> G["JSON response"]

Creating the project with the Toolbox

The Vapor Toolbox is a command-line tool that clones the vapor/template repository, asks the questions the template declares in its manifest.yml, renders the files and (by default) initialises a Git repository. Creating the section’s running project:

$ vapor new bookshelf

The Toolbox README says the tool "asks for the required details". The template’s manifest.yml defines these questions:

Question Answers Effect on the generated project

Would you like to use Fluent (ORM)?

yes or no

adds the Fluent package and Models/, DTOs/, Migrations/ files with a Todo example and a TodoController; routes for /todos are registered

Which database would you like to use? (only after yes)

Postgres (recommended), MySQL or SQLite

adds the matching Fluent driver package and its configuration in configure.swift

Would you like to use Leaf (templating)?

yes or no

adds the Leaf package and Resources/Views/index.leaf

For Bookshelf the pages of this section start without Fluent and Leaf and add them on the pages that need them, so the example below answers "no" to both questions. The Toolbox’s flags, from its README:

Flag Meaning

-n

answer "no" to every question; the quickest way to a plain project (the official guide uses it)

--output <folder>

create the project in another folder

--no-git

do not create a Git repository

--no-commit

create the repository but skip the initial commit

--template <url>

use another Git repository as the template instead of vapor/template

--branch <name>

branch of the template to use; defaults to main

--manifest <path>

YAML or JSON manifest to use; by default the Toolbox looks for manifest.yml, then manifest.json, in the template root

--help

lists the commands; with --template, --branch or --manifest it shows the options of that custom template

new is the default subcommand, so vapor bookshelf -n is the same as vapor new bookshelf -n. The README does not document --fluent or --leaf flags; those names only appear as variables inside a template manifest, which is why a custom template can define its own flags.

$ vapor new bookshelf -n
$ cd bookshelf
$ vapor new bookshelf-api --template https://github.com/vapor/template --branch main --no-git

The second command is only an illustration of the long options; --template https://github.com/vapor/template is the default and --branch main too. Replace them with your own company template repository to share a project skeleton.

If you cannot or do not want to install the Toolbox, copy the files listed in What the template generates by hand, or clone the template and edit it; the Toolbox adds nothing that swift build needs.

What the template generates

The current template names the executable target, and its Sources/ and Tests/ folders, after the project: manifest.yml and Package.swift use {{name}}, so vapor new bookshelf creates a bookshelf target and you run it with swift run bookshelf serve. This section names the target App by convention (the name the template repository itself and the Vapor docs use), so rename it in Package.swift, the folder names and the Dockerfile, or read App as your project name in every command. Without Fluent and Leaf, and with the target named App, the project contains:

bookshelf/
  Package.swift
  Dockerfile
  docker-compose.yml
  Public/
  Sources/App/
    configure.swift
    entrypoint.swift
    routes.swift
    Controllers/
  Tests/AppTests/
    AppTests.swift

With Fluent the Toolbox adds Models/Todo.swift, DTOs/TodoDTO.swift, Migrations/CreateTodo.swift and Controllers/TodoController.swift; with Leaf it adds Resources/Views/index.leaf. The folder structure and Package.swift are explained on the Project Structure and Swift Package Manager page. The generated Dockerfile and docker-compose.yml are explained on the Deployment with Docker page. The three Swift files you meet first are:

// Sources/App/routes.swift, as generated
import Vapor

func routes(_ app: Application) throws {
    app.get { req async in
        "It works!"
    }

    app.get("hello") { req async -> String in
        "Hello, world!"
    }
}
// Sources/App/configure.swift, as generated (no Fluent, no Leaf)
import Vapor

func configure(_ app: Application) async throws {
    try routes(app)
}

entrypoint.swift contains the @main enum Entrypoint that detects the environment, bootstraps logging, creates the Application with Application.make(_:), runs configure(_:) and app.execute(), and shuts the application down; its anatomy is the subject of the Application Lifecycle and the Entrypoint page. Two details matter now. First, execute() parses the command-line arguments and runs the command they select, which is why swift run App serve and swift run App routes do different things with the same executable. Second, when you give no command, swift run runs the default command, serve.

Building and running

From the project folder:

$ swift build
Building for debugging...
...
Build complete! (146,11 s)

$ swift run App serve
[ NOTICE ] Server started on http://127.0.0.1:8080

The first build clones and compiles Vapor, SwiftNIO and around 130 other dependencies (about two and a half minutes on the machine used here, including a cold dependency cache); incremental builds take seconds. swift run App serve builds if needed and starts the server. Three things to know about the output:

  • The Vapor 4 hello-world guide prints [ INFO ] Server starting on http://127.0.0.1:8080. With Vapor 4.122.2 the default log level shows [ NOTICE ] Server started on ... once the socket is bound (the "starting" line is logged at debug, see the --log debug run below). Do not write tests that parse these lines.

  • The server binds 127.0.0.1 by default, so it is reachable only from the same machine. In a container or on a VM you must bind 0.0.0.0 (see the options below).

  • The App in swift run App is the executable target’s name in Package.swift (App by this section’s convention, the project name in a fresh vapor new project). swift run without a name works when the package has a single executable.

In Xcode, open the folder (or Package.swift), choose the App scheme and a Mac destination, and press Run. To pass a command (for example serve) use "Arguments Passed On Launch" of the scheme; to make relative paths such as Public/ resolve, set the scheme’s custom working directory to the project folder (see the Installation and Toolchain page for details).

Now call the two generated routes:

$ curl -i localhost:8080/
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 9
connection: keep-alive
date: Sat, 17 Oct 2026 11:19:32 GMT

It works!

$ curl -i localhost:8080/hello
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 13
connection: keep-alive
date: Sat, 17 Oct 2026 11:19:32 GMT

Hello, world!

A handler returning a String produces text/plain; charset=utf-8: String conforms to ResponseEncodable. The route app.get { ... } with no path components is the root path /. Unmatched requests are answered by Vapor, not by your code, in the same JSON error shape you will use for your own failures:

$ curl -i localhost:8080/nope
HTTP/1.1 404 Not Found
content-type: application/json; charset=utf-8
content-length: 35
connection: keep-alive
date: Sat, 17 Oct 2026 11:19:32 GMT

{"error":true,"reason":"Not Found"}

$ curl -i -X POST localhost:8080/hello
HTTP/1.1 404 Not Found
content-type: application/json; charset=utf-8
content-length: 35
...

{"error":true,"reason":"Not Found"}

Unlike axum, Vapor answers a request with a known path but a method that has no route with 404 Not Found, not 405 Method Not Allowed, and sends no Allow header. The server also logs one line per request at info level:

[ INFO ] GET /hello [request-id: F8B9CC35-CE6A-4DE2-9BD5-A24C65D0A731]

The first JSON route

JSON in Vapor is handled by the Content protocol: a type that conforms to Content is Codable plus RequestDecodable/ResponseEncodable, with a default media type (JSON). Returning a Content value from a handler encodes it as the response; req.content.decode(_:) decodes the request body into one. Path components that start with a colon are parameters: ":id" stores the matched segment in req.parameters, and req.parameters.get(_:as:) reads it as any LosslessStringConvertible type, returning nil when it is missing or cannot be converted.

Replace routes.swift with the following. It keeps the two generated routes, adds a read route with a path parameter, and a create route that decodes a JSON body:

import Vapor

struct BookDTO: Content {
    var id: Int
    var title: String
    var author: String
}

struct NewBook: Content {
    var title: String
    var author: String
}

func routes(_ app: Application) throws {
    app.get { req async in
        "It works!"
    }

    app.get("hello") { req async -> String in
        "Hello, world!"
    }

    app.get("books", ":id") { req async throws -> BookDTO in
        guard let id = req.parameters.get("id", as: Int.self) else {
            throw Abort(.badRequest, reason: "id must be an integer")
        }
        return BookDTO(id: id, title: "Dune", author: "Frank Herbert")
    }

    app.post("books") { req async throws -> BookDTO in
        let input = try req.content.decode(NewBook.self)
        return BookDTO(id: 1, title: input.title, author: input.author)
    }
}

Point by point:

  • app.get("books", ":id") registers GET /books/:id; the path is given as separate components, not as one string with slashes ("books/:id" also works, but the component form is the documented one and the form that composes with route groups).

  • req.parameters.get("id", as: Int.self) is nil for /books/abc. Throwing Abort(.badRequest, reason:) is how a handler reports an HTTP error; Abort conforms to AbortError, which Vapor’s ErrorMiddleware turns into a response with that status and a JSON body.

  • The handlers are async throws: Vapor 4 routes accept async closures directly and the older EventLoopFuture style is only for legacy code. Closures passed to app.get are @Sendable; capturing only Sendable values (as here) keeps the Swift 6 build clean.

  • BookDTO and NewBook are structs. Content refines Sendable, so a conforming struct must have Sendable stored properties (which Swift 6 requires to cross isolation boundaries) and a conforming class must satisfy the Sendable rules itself (final, immutable state).

  • The DTO naming (BookDTO, NewBook) separates the HTTP shape from the persistence model that Fluent adds later, matching the DTOs/ folder the template creates.

Run it and call it:

$ curl -i localhost:8080/books/42
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 49
connection: keep-alive
date: Sat, 17 Oct 2026 11:19:32 GMT

{"author":"Frank Herbert","title":"Dune","id":42}

$ curl -i localhost:8080/books/abc
HTTP/1.1 400 Bad Request
content-type: application/json; charset=utf-8
content-length: 47
connection: keep-alive
date: Sat, 17 Oct 2026 11:19:32 GMT

{"error":true,"reason":"id must be an integer"}

$ curl -i -X POST localhost:8080/books -H 'Content-Type: application/json' -d '{"title":"Dune","author":"Frank Herbert"}'
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 48
connection: keep-alive
date: Sat, 17 Oct 2026 11:19:32 GMT

{"title":"Dune","id":1,"author":"Frank Herbert"}

$ curl -i -X POST localhost:8080/books -H 'Content-Type: application/json' -d '{"title":"Dune"}'
HTTP/1.1 400 Bad Request
content-type: application/json; charset=utf-8
content-length: 152
connection: keep-alive
date: Sat, 17 Oct 2026 11:19:32 GMT

{"error":true,"reason":"No such key 'author' at path ''. No value associated with key CodingKeys(stringValue: \"author\", intValue: nil) (\"author\")."}

Observations from the real output:

  • JSON object keys are not in declaration order (author before title before id). JSON objects are unordered; do not compare response bodies as strings in tests, decode them instead.

  • The create route answers 200 OK, not 201 Created. Setting a status is one more step (return a Response, or conform the returned type to AsyncResponseEncodable with a status); the Requests and Responses and Error Handling pages show how.

  • A body that cannot be decoded is reported as 400 Bad Request automatically, with the decoder’s message as the reason. That message names Swift types such as CodingKeys, so a public API usually validates input and returns its own messages; the Validation page shows how.

  • The failed decode and the Abort are both logged at warning level, with method, url and request-id metadata:

[ WARNING ] Abort.400: id must be an integer [method: GET, request-id: 8ACBD295-1E49-4A14-A33B-A3FA785F651B, url: /books/abc, userAgent: [curl/8.7.1]]

If the request has no Content-Type header at all, Vapor cannot pick a decoder and answers 415 Unsupported Media Type with the reason "Can’t decode data without a content type". If it declares another type, decoding uses that type’s decoder and fails with a 400 Bad Request whose reason describes the problem: for text/plain it says that plaintext decoding does not support dictionaries, and curl -d without -H sends application/x-www-form-urlencoded, whose decoder finds no title field. The Content protocol, content negotiation and custom encoders are covered on the Content and Codable page.

Listing routes

swift run App routes prints the routes registered on the application and exits without starting a server:

$ swift run App routes
+------+------------+
| GET  | /          |
+------+------------+
| GET  | /hello     |
+------+------------+
| GET  | /books/:id |
+------+------------+
| POST | /books     |
+------+------------+

The routes command runs configure(_:) and then prints the router’s table, so it is the quickest way to see what configure and the route-registering code produced, including routes added by controllers and route groups. With Fluent the table also contains the /todos routes (the official routing page shows DELETE /todos/:todoID in its example). Each route also exposes a path-parameter name (:id above), and catch-all (**) and anything (*) components show up in the same column.

Commands and options

The executable is a Commands host: the first argument that is not an option selects the command. Vapor’s own help lists what is registered (without Fluent there is no migrate command; Fluent adds it):

$ swift run App --help
Usage: .../App <command>

Commands:
  routes Displays all registered routes.
   serve Begins serving the app over HTTP.

Use `.../App <command> [--help,-h]` for more information on a command.

$ swift run App serve --help
Usage: .../App serve [--hostname,-H] [--port,-p] [--bind,-b] [--unix-socket]

Begins serving the app over HTTP.

Options:
     hostname Set the hostname the server will run on.
         port Set the port the server will run on.
         bind Convenience for setting hostname and port together.
  unix-socket Set the path for the unix domain socket file the server will bind to.

The options available on every command come from the environment and logging setup done in entrypoint.swift (Environment.detect() and LoggingSystem.bootstrap(from:)):

Option Meaning Example

--env <name>, -e <name>

the environment: development (default, also dev), production (also prod), testing (also test) or any custom name; when the option is absent the VAPOR_ENV process environment variable is used

swift run App serve --env production

--log <level> (no short form)

the log level: trace, debug, info, notice, warning, error, critical; the default is info, or notice in production

swift run App serve --log debug

--hostname <host>, -H <host>

the host or IP address to bind (serve)

--hostname 0.0.0.0

--port <port>, -p <port>

the TCP port to bind (serve)

--port 9090

--bind <host:port>, -b <host:port>

hostname and port together; cannot be combined sensibly with the two above

-b 127.0.0.1:7000

--unix-socket <path>

bind a Unix domain socket instead of TCP

--unix-socket /tmp/bookshelf.sock

Results observed on this build:

$ swift run App serve --hostname 0.0.0.0 --port 9090
[ NOTICE ] Server started on http://0.0.0.0:9090

$ swift run App serve -b 127.0.0.1:7000 --env production
[ NOTICE ] Server started on http://127.0.0.1:7000

$ swift run App serve --env staging --log debug -b 127.0.0.1:7001
[ DEBUG ] Could not load .env.staging file: Not found: Unable to open or create file at path '.env.staging', ...
[ DEBUG ] Could not load .env file: Not found: Unable to open or create file at path '.env', ...
[ DEBUG ] Server starting on http://127.0.0.1:7001 (Vapor/HTTPServer.swift:431)
[ NOTICE ] Server started on http://127.0.0.1:7001 (Vapor/HTTPServer.swift:458)

$ swift run App serve --unix-socket /tmp/vapor-gs.sock
[ NOTICE ] Server started on http+unix: /tmp/vapor-gs.sock

The --env staging run shows that an arbitrary environment name is accepted and that Vapor tries to load dotenv files (.env.staging first, then .env) from the working directory, logging at debug when they are absent. That is how local development configuration is supplied without exporting variables; never commit those files. Environment handling (the Environment type, Environment.get(_:), dotenv precedence and secrets) belongs to the Configuration and Environment page; here it is enough to know that development is the default and that production raises the default log level from info to notice.

Executing the program with an unknown command fails with the registered names:

$ swift run App bogus
[ WARNING ] .unknownCommand("bogus", available: ["routes", "serve"])
Swift/ErrorType.swift:254: Fatal error: Error raised at top level: .unknownCommand("bogus", available: ["routes", "serve"])

Common first-build and first-run errors

Symptom Cause Fix

package '...' is using Swift tools version 6.3.0 but the installed version is 6.0.0 (or similar)

the toolchain is older than the // swift-tools-version: comment in Package.swift; the current template needs 6.3

install a newer toolchain (Xcode or Swiftly; see the installation page) or, for your own manifest, lower the tools version to 6.0, the minimum Vapor 4.122.2 itself builds with. The wording was reproduced locally with a deliberately too-high manifest: error: 'pkg-gs-err': package 'pkg-gs-err' is using Swift tools version 99.0.0 but the installed version is 6.4.0

no such module 'Vapor'

the target does not list .product(name: "Vapor", package: "vapor") as a dependency, or the dependencies were not resolved

add the product to the target’s dependencies, run swift package resolve

errors in a package that previously built, after switching Xcode or toolchain

stale build artifacts

swift package clean; if that is not enough, rm -rf .build and swift package resolve

a platform or availability error mentioning macOS or iOS versions

the manifest’s platforms: is lower than what a dependency or an API you use needs; Vapor 4.122.2 itself still builds with .macOS(.v10_15), so the message comes from another dependency or from an API you call, and its wording varies

keep the template’s platforms: [.macOS(.v13)] or higher

Linux build fails with missing system libraries

a libssl, zlib or libcurl development package is missing on a minimal image

install the development packages listed in the Installation and Toolchain page, or build in the official swift Docker image (see Deployment with Docker)

[ WARNING ] bind(descriptor:ptr:bytes:): Address already in use) (errno: 48) followed by Fatal error: Error raised at top level

another process (often a previous swift run still alive, or Xcode’s run) already listens on port 8080 (errno 48 on macOS, 98 on Linux)

stop the other process (lsof -i :8080), or choose another port: swift run App serve --port 8081

.invalidOptionType("port", type: Int)

a non-numeric value was given to --port

pass a number

curl: (7) Failed to connect from another machine or from inside a container

the server is bound to 127.0.0.1 (the default)

bind 0.0.0.0: swift run App serve --hostname 0.0.0.0

400 Bad Request with a CodingKeys message

the JSON body is missing a required key or has the wrong type

send the required fields; in a real API validate and translate the message

a file under Public/ returns 404

the server was started from another working directory, or FileMiddleware was not enabled (it is not enabled in the generated configure.swift shown above; enable it with app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)))

run from the project folder or set Xcode’s custom working directory

A first handler that captures mutable state is the typical Swift 6 stumbling block. Closures passed to app.get are @Sendable, so mutating a captured var from one, or capturing a non-Sendable class, is a data race: handlers run concurrently on several event loops. Because Vapor 4 marks its route-registration methods @preconcurrency, the Swift 6 language mode reports these captures as warnings (for example "mutation of captured var 'hits' in concurrently-executing code") rather than errors, so the build succeeds and the race ships unless you treat the warnings as errors. A mutable global, on the other hand, is an error in the Swift 6 language mode. Keep request state in the Request, shared state in an actor or in app.storage, and mutable globals out of routes; the Swift Concurrency in Vapor page develops this and Actors, Isolation and Sendable explains the language side.

Version notes and outdated material

  • swift run Run and a Sources/Run executable (Vapor 3 and early Vapor 4 templates, including the books written for them) became a single executable target that holds all the code (named after the project by the current template, App in this section); the template for Vapor 4.122 has no Run target.

  • vapor xcode and swift package generate-xcodeproj are gone from the Toolbox 20 and from modern Xcode; open Package.swift in Xcode and use swift build/swift run. Likewise the check.vapor.sh script and the Vapor APT repository are outdated; the Installation and Toolchain page covers current methods.

  • EventLoopFuture return types (-> EventLoopFuture<String>) appear in the books and old tutorials. They still work in Vapor 4, but new code uses async/await, as above.

  • The Vapor 3 Router/routes(_ router: Router) function and try router.get(...) became routes(_ app: Application) and app.get(...) in Vapor 4; Vapor 3 services and containers (config.register(...)) are gone.

  • Older books show the entrypoint as main.swift with app.run(); the current template uses @main enum Entrypoint, Application.make and app.execute(), with the old forms deprecated.

  • Vapor 5 (5.0.0-beta.4 at the time of writing) is in beta, not released, and changes some APIs. This page’s code is written for Vapor 4.122.x; check the What’s New, Upgrading and Vapor 5 page before moving.