Server-side Swift is more production-ready than its reputation suggests. Vapor 4 sits on SwiftNIO, ships with a real ORM (Fluent) and a migration system, and compiles to a single fast binary. The catch is that almost nobody's muscle memory covers deploying it — so the same three or four gotchas bite everyone. Here's the whole path, gotchas included.
The production build
Vapor projects are plain Swift packages. A release build is:
swift build -c releaseOn Linux, add --static-swift-stdlib so the Swift runtime libraries are baked into the binary — otherwise your runtime container needs the Swift toolchain's shared libraries installed, which defeats the point of a slim image:
swift build -c release --static-swift-stdlibThe binary lands at .build/release/App (the default executable target name from vapor new). It's self-contained enough to copy into a bare Ubuntu image.
Two things to know before your first CI build. Swift release builds are slow — several minutes even for modest projects, dominated by compiling dependencies like SwiftNIO. And they're memory-hungry; if your build environment is tightly memory-capped and the compiler gets OOM-killed, you'll see cryptic "signal 9" failures rather than a clear error. Both facts argue for Docker layer caching that keeps dependency compilation out of the hot path, which the Dockerfile below does.
The 127.0.0.1 trap
This is the number one "works locally, 502 in production" issue with Vapor. By default, serve binds to 127.0.0.1:8080 — unreachable from outside the container. Production needs:
./App serve --env production --hostname 0.0.0.0 --port 8080--env production matters too: it turns off the verbose request logging you get in development and tells Vapor (and your own Environment-conditional code) which mode it's in. If the platform assigns a port via environment variable, read it in configure.swift instead of hardcoding:
app.http.server.configuration.hostname = "0.0.0.0"
app.http.server.configuration.port = Environment.get("PORT").flatMap(Int.init) ?? 8080Wiring PostgreSQL through DATABASE_URL
The Fluent Postgres driver accepts a postgres:// connection string directly, which is exactly what a managed platform injects. In configure.swift:
import Fluent
import FluentPostgresDriver
public func configure(_ app: Application) async throws {
if let databaseURL = Environment.get("DATABASE_URL") {
try app.databases.use(.postgres(url: databaseURL), as: .psql)
} else {
// Local development fallback
app.databases.use(.postgres(configuration: .init(
hostname: "localhost",
username: "vapor",
password: "vapor",
database: "vapor_dev",
tls: .disable
)), as: .psql)
}
app.migrations.add(CreateTodo())
}One detail worth checking against your driver version: TLS behavior when connecting via URL depends on the connection string and the driver's defaults. If you hit TLS negotiation errors, construct an SQLPostgresConfiguration from the URL explicitly and set its TLS mode to match what your database endpoint expects, rather than fighting the defaults.
Migrations: run them on purpose
Fluent migrations are Swift types you register in configure.swift:
struct CreateTodo: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema("todos")
.id()
.field("title", .string, .required)
.field("done", .bool, .required, .sql(.default(false)))
.create()
}
func revert(on database: Database) async throws {
try await database.schema("todos").delete()
}
}Locally you run them with:
swift run App migrateVapor asks for confirmation; in scripts and containers, skip the prompt:
./App migrate --yesThere's also serve --auto-migrate, which migrates at boot. It's convenient, and for a single-instance hobby app it's fine. For anything with multiple replicas rolling out simultaneously, prefer migrations as an explicit step before the new version takes traffic — two instances racing to apply the same migration is a failure mode you don't want to debug at 2 a.m. My rule: --auto-migrate until you have more than one replica, explicit migrate --yes after.
The Dockerfile
Two stages: the full Swift toolchain builds, a minimal Ubuntu image runs. Copying Package.* and resolving before copying the source means dependency compilation is cached until you actually change dependencies — which, given Swift build times, is the single highest-value line in this file.
FROM swift:6.0-noble AS build
WORKDIR /build
COPY Package.* ./
RUN swift package resolve
COPY . .
RUN swift build -c release --static-swift-stdlib
FROM ubuntu:noble
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /build/.build/release/App ./
# Only if your app serves files or uses Leaf templates:
COPY --from=build /build/Public ./Public
EXPOSE 8080
ENTRYPOINT ["./App"]
CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]Note the Public directory copy: the binary doesn't embed static files or Leaf templates, and Vapor resolves them relative to the working directory. Forgetting Public (or Resources for Leaf) is the second most common Vapor deploy bug after the hostname one. If your app uses FoundationNetworking on Linux — URLSession for outbound calls — add libcurl4 to the runtime packages.
Deploying on PandaStack
Swift isn't a buildpack language on most platforms, and that's fine — a Dockerfile is the honest way to ship it, and PandaStack builds any Dockerfile. The flow:
- 1Create a managed PostgreSQL instance (14.x or 16.x) from the dashboard. Daily backups are automatic, retained 7 days on free, 15 on Pro, 30 on Premium.
- 2Connect your repo as a container app. The build runs in rootless BuildKit inside an ephemeral Kubernetes Job pod — no privileged Docker socket anywhere near your code.
- 3Attach the database.
DATABASE_URLis injected into the app automatically, which theconfigure.swiftabove picks up with zero extra configuration. - 4Push to Git. Build and deploy run automatically, and the build logs stream live — genuinely useful with Swift, where a long compile with no output looks identical to a hung build unless you can see the compiler working through modules.
Set LOG_LEVEL and any app secrets as environment variables in the app settings.
Fair warnings for Swift specifically: release builds will eat more of your build-minute budget than a Node or Go app would (the free tier includes 300 build-pipeline minutes a month — the layer-caching Dockerfile above is how you stay comfortable inside it). And free-tier apps scale to zero when idle; Vapor's compiled binary starts fast, so cold starts are gentler here than for JVM apps, but the first request after a quiet stretch still pays the wake-up cost. Paid tiers run on stable, always-warm nodes.
Recap
| Gotcha | Fix |
|---|---|
| Binds to 127.0.0.1 by default | --hostname 0.0.0.0 in the container CMD |
| Migration prompt hangs scripts | migrate --yes |
| Racing migrations on multi-replica rollouts | Explicit migrate step, not --auto-migrate |
| Missing static files/templates | Copy Public/Resources into the runtime image |
| Slow rebuilds | Resolve Package.* in its own cached Docker layer |
| Shared-library errors at runtime | --static-swift-stdlib at build time |
Vapor deploys like any other compiled-language service once you know where the sharp edges are. If you want to see it running with the database wired up for you, try it on https://pandastack.io.