Back to Blog
Tutorial6 min read2026-07-13

How to Deploy a Vapor (Swift) App with PostgreSQL

Ship a Vapor 4 app to production: release builds, the 127.0.0.1 trap, Fluent migrations, DATABASE_URL wiring, and a Dockerfile that works.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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 release

On 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-stdlib

The 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) ?? 8080

Wiring 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 migrate

Vapor asks for confirmation; in scripts and containers, skip the prompt:

./App migrate --yes

There'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:

  1. 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.
  2. 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.
  3. 3Attach the database. DATABASE_URL is injected into the app automatically, which the configure.swift above picks up with zero extra configuration.
  4. 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

GotchaFix
Binds to 127.0.0.1 by default--hostname 0.0.0.0 in the container CMD
Migration prompt hangs scriptsmigrate --yes
Racing migrations on multi-replica rolloutsExplicit migrate step, not --auto-migrate
Missing static files/templatesCopy Public/Resources into the runtime image
Slow rebuildsResolve 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.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also