Deployment Targets
|
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 with tools version 6.3, as the
official template does), with fluent 4.13.0, fluent-postgres-driver 2.14.0, Swiftly 1.2.0 and swift-aws-lambda-runtime 3.0.2, checked on
2026-10-17. Vapor 5 is at 5.0.0-beta.4 and is not released yet; the deployment mechanics on this page do not depend on Vapor 5 specifics. The Swift listings are the real source files of a scratch package that compiled with zero warnings and passes three VaporTesting tests. The nginx
configuration was syntax-checked with nginx -t and exercised with curl against a running Vapor process (nginx 1.31.6 in a container); the systemd
unit was checked with systemd-analyze verify (systemd 255) and the Supervisor file was parsed by Supervisor 4.2.5; the Kubernetes manifests and the ECS task
definition were parsed but not applied to a cluster or an account. The Fly.io, Cloud Run, AWS and Heroku steps were not run here: they are taken from the
vendors' current documentation, linked in each section, and need an account. The page extends the Bookshelf service of the earlier pages of this section.
The previous page on this subject in a typical Vapor book sets up one Heroku app, then one EC2 instance, then Docker. The useful way to think about deployment in 2026 is the
opposite: a Vapor service is one statically linkable executable that listens on a port, reads its settings from the environment and stops on SIGTERM. Every
target below is a different answer to the same six questions, so the page first states them, then walks through the targets from the smallest (one command on
Fly.io) to the largest (Kubernetes). The Docker image that most of them run is the subject of the Deployment with Docker page; the production checklist
and tuning are on the Production and Performance page.
Choosing a target
| Target | You provide | The platform provides | Good fit |
|---|---|---|---|
Fly.io |
A |
Builder, registry, TLS, routing, secrets, managed Postgres, rolling deploys with health checks |
Small and medium services, one command from laptop or CI, multi-region later |
VPS or droplet |
Everything: OS updates, Swift or a prebuilt binary, systemd, nginx, certificates, backups |
A Linux machine |
Full control, a fixed monthly price, learning what the platforms hide |
AWS ECS on Fargate |
An image in a registry, a task definition, a load balancer, a VPC |
Scheduling, restarts, IAM, Secrets Manager, RDS integration |
Teams already on AWS |
Google Cloud Run |
An image |
Per-request scaling to zero, TLS, revisions and traffic splitting, Secret Manager |
Spiky traffic, small teams on Google Cloud |
Kubernetes |
Manifests (Deployment, Service, Ingress, Job, Secret), a cluster |
Scheduling, probes, rolling updates, service discovery |
Many services, an existing platform team |
AWS Lambda |
A function that speaks the Lambda Runtime API |
Scaling, billing per invocation |
Event handlers, not a long-running Vapor server |
Nothing here is Vapor-specific on the platform side, which is the point: a Vapor image behaves like any other container. The Vapor-specific part is the contract in the next section.
What every target needs
The contract
Six properties make a Vapor service deployable anywhere:
-
Bind to the right address.
servelistens on127.0.0.1:8080by default, which is unreachable from outside a container or a load balancer. In a container pass--hostname 0.0.0.0 --port 8080(the official template’sCMDdoes). Behind nginx on the same machine keep127.0.0.1. The flags are covered on the Server Configuration page. -
Run in the production environment.
--env production(orVAPOR_ENV=production) selects the production log level and anyapp.environment == .productionbranches of your code; see Configuration and Environment. -
Take secrets and connections from the environment. Platforms hand out one
DATABASE_URL, not five variables. The template readsDATABASE_HOST,DATABASE_USERNAMEand so on, so a service deployed to Fly.io, Heroku or Cloud Run needs the small adapter shown below. -
Run migrations exactly once per release (see Migrations & Schema), before the new version takes traffic, not at start-up of every instance (two instances racing on the same schema is the classic failure). The command is
App migrate --yes, documented on the Commands page; every target has a different place to put it, summarised in the table below. -
Stop on
SIGTERM. Theservecommand traps bothSIGINTandSIGTERM, stops accepting connections, lets in-flight requests finish and closes the server aftershutdownTimeout(10 seconds by default; the lifecycle is on Application Lifecycle and Entrypoint). The platform’s grace period must be longer than that: systemdTimeoutStopSec, KubernetesterminationGracePeriodSeconds, ECSstopTimeout, Fly.iokill_timeout. The process must receive the signal, so run the executable directly (exec-formENTRYPOINT, nosh -cwrapper). -
Expose a health endpoint. Vapor has none built in. A liveness route proves the process answers; a readiness route also proves it can reach its database.
| Target | Where migrations run |
|---|---|
Fly.io |
|
VPS |
A step in the deploy script, before |
ECS on Fargate |
A one-off |
Cloud Run |
A Cloud Run job with the same image and |
Kubernetes |
A |
Configuration and health routes in Swift
The listing below is the part of Bookshelf that a platform sees. configureDatabase accepts either DATABASE_URL or the template’s discrete variables; the URL form is parsed by PostgresKit, which
understands postgres://user:password@host:port/database?tlsmode=require (also written sslmode= as in libpq; the default is prefer, and whenever TLS is used the server certificate is fully verified). A password with
special characters must be percent-encoded in the URL. maxConnectionsPerEventLoop defaults to 1, and Vapor starts one event loop per CPU core, so a four-core
instance holds four connections by default; size the pool against the database’s connection limit multiplied by the number of instances.
import Fluent
import FluentPostgresDriver
import FluentSQLiteDriver
import Vapor
struct MissingSecret: Error {
let variable: String
}
/// Reads the connection from the environment the platform provides and registers the health routes.
func configure(_ app: Application) async throws {
try configureDatabase(app)
try routes(app)
}
func configureDatabase(_ app: Application) throws {
if app.environment == .testing {
app.databases.use(.sqlite(.memory), as: .sqlite)
return
}
if let url = Environment.get("DATABASE_URL") {
// Fly.io, Heroku, Cloud Run secrets and Kubernetes Secrets usually provide one URL.
// Query: ?tlsmode=require demands TLS; the default is "prefer".
app.databases.use(try .postgres(url: url, maxConnectionsPerEventLoop: 4), as: .psql)
} else {
// The official template reads discrete variables instead. Its "vapor_password" fallback is only for
// development: in production a missing password stops the application at startup.
let password: String
if let fromEnvironment = Environment.get("DATABASE_PASSWORD") {
password = fromEnvironment
} else if app.environment == .production {
throw MissingSecret(variable: "DATABASE_PASSWORD")
} else {
password = "vapor_password"
}
app.databases.use(.postgres(configuration: .init(
hostname: Environment.get("DATABASE_HOST") ?? "localhost",
port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? SQLPostgresConfiguration.ianaPortNumber,
username: Environment.get("DATABASE_USERNAME") ?? "vapor_username",
password: password,
database: Environment.get("DATABASE_NAME") ?? "vapor_database",
tls: .prefer(try .init(configuration: .clientDefault))
)), as: .psql)
}
}
Source: vapor/template: configure.swift
The routes file adds the two probes and a diagnostic route that is used later to look at what a reverse proxy does to the client address:
import Fluent
import SQLKit
import Vapor
func routes(_ app: Application) throws {
// Liveness: the process is up and the event loops answer. No dependencies are touched.
app.get("healthz") { _ in HTTPStatus.ok }
// Readiness: the instance can serve traffic, which needs the database.
app.get("readyz") { req async throws -> HTTPStatus in
guard let sql = req.db as? any SQLDatabase else { throw Abort(.internalServerError) }
do {
_ = try await sql.raw("SELECT 1").all()
return .ok
} catch {
req.logger.warning("readiness check failed", metadata: ["error": "\(error)"])
throw Abort(.serviceUnavailable)
}
}
// The address of the caller as Vapor derives it behind a reverse proxy.
app.get("whoami") { req -> String in
req.peerAddress?.ipAddress ?? "unknown"
}
}
Source: Vapor docs: Routing
Why two routes? A liveness probe that checks the database turns a database outage into a restart storm of otherwise healthy instances, and a readiness probe that does not check it sends traffic to
instances that can only answer 500. Use /healthz for "restart me" decisions (Kubernetes liveness, systemd watchdog scripts, Fly.io machine checks) and /readyz for "send me traffic" decisions
(Kubernetes readiness). Load balancers that can only have one check, such as an AWS target group, should usually use /healthz, so that a short database failover does not take the whole fleet out of rotation.
The three VaporTesting tests of the scratch package pass: /healthz answers 200, /readyz answers 200 against in-memory SQLite in the testing environment, and req.peerAddress returns the first entry of
X-Forwarded-For, which matters in the nginx section.
Fly.io
Fly.io runs your image as a Firecracker micro-VM ("Machine") close to your users, behind its own proxy that terminates TLS. Vapor needs nothing special: it needs the official Dockerfile of the template (see Deployment with Docker) and a fly.toml.
brew install flyctl # Linux: curl -L https://fly.io/install.sh | sh
fly auth login
cd Bookshelf # contains the Dockerfile
fly launch # asks for name, region and database, writes fly.toml
fly deploy
fly launch writes fly.toml and can create a database; it offers to deploy straight away, and fly deploy is the command for every later release. Fly builds the image from the Dockerfile, starts a Machine, waits for its checks and rolls back to the previous release if
they fail. The file below is what a Bookshelf fly.toml looks like after the changes that matter for Vapor:
app = "bookshelf-example"
primary_region = "ams"
[deploy]
release_command = "migrate --yes"
[env]
LOG_LEVEL = "info"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 1
processes = ["app"]
[[http_service.checks]]
grace_period = "10s"
interval = "15s"
method = "GET"
path = "/healthz"
timeout = "2s"
[processes]
app = ""
worker = "queues"
[[vm]]
memory = "512mb"
cpu_kind = "shared"
cpus = 1
-
release_commandreplaces the image’sCMDbut not itsENTRYPOINT. With the template’sENTRYPOINT ["./App"]Fly therefore runs./App migrate --yesin a temporary Machine that has the new image, the app’s secrets and the private network, once per deploy; a non-zero exit stops the release. It has a five-minute limit by default (release_command_timeout). -
internal_port = 8080matches the template’sCMD, which listens on0.0.0.0:8080. Fly’s default is also 8080. -
[processes]is how a queue worker runs next to the web process: Machines of theappgroup run the image’s default command, Machines of theworkergroup run./App queues(see Queues and Background Jobs). Do not change theENTRYPOINTorCMDin theDockerfilefor this. Once a[processes]section exists, flyctl treats it as the complete list, which is whyappis listed explicitly, and theprocesses = ["app"]line keeps the HTTP service off the worker. Scale each group separately withfly scale count app=2 worker=1. -
The
/healthzcheck is an HTTP check run by Fly against the Machine;grace_periodgives the process time to boot before the first check counts. -
auto_stop_machineswithmin_machines_running = 1keeps one Machine warm. Setting it to0scales to zero, and the first request then pays the start-up time. -
Fly sends
SIGINTto stop a Machine by default, then waitskill_timeoutbefore a forced stop. Vapor handlesSIGINTandSIGTERMthe same way, so the default works; setkill_timeoutabove the server’sshutdownTimeoutif you raise that.
Postgres and secrets
Fly.io’s current database offer is Managed Postgres (fly mpg): backups, failover and connection pooling are handled for you. The older self-managed "Fly Postgres" apps (fly pg create, fly pg attach) that the Vapor documentation still describes are
the unmanaged option; Fly documents them as a separate product. Attaching an app stores the connection string as the DATABASE_URL secret, which the configureDatabase function above reads:
fly mpg create # name, plan, region
fly mpg attach <cluster-id> -a bookshelf-example # sets DATABASE_URL (pooled URL) as a secret
fly secrets set JWT_SIGNING_KEY="<placeholder>" # staged until the next deploy; use `fly secrets import` to keep it out of shell history
fly logs
fly ssh console
Secrets are encrypted at rest and arrive in the Machine as environment variables, so Environment.get and the patterns of the Configuration and Environment page work unchanged. Non-secret variables go in [env] in fly.toml.
A VPS or DigitalOcean droplet
A virtual server gives you the longest list of chores and the most transparent system. The flow is the same on any provider; the official guide uses a DigitalOcean droplet running Ubuntu. A request travels like this:
TLS and static files] N -->|HTTP 127.0.0.1:8080| V[Vapor App
systemd unit] V --> P[(PostgreSQL)] N -.->|/css /js /images| F[Public directory]
Provision and secure the machine
Create the droplet with a current Ubuntu LTS release and an SSH key, then:
ssh root@your_server_ip
ufw allow OpenSSH
ufw allow 'Nginx Full' # after nginx is installed; opens 80 and 443
ufw enable
adduser deploy # the human/CI account that ships releases; the service runs as "vapor" below
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
Open only 22, 80 and 443. In particular do not ufw allow 8080 as the official guide does for its first smoke test: with nginx in front, Vapor is bound to 127.0.0.1 and the port stays closed to the world. Also disable password logins over SSH.
Swift on the server, or no Swift at all
The official guide installs Swift on the server with Swiftly and builds there. Swiftly is the official toolchain manager for Linux and macOS:
curl -L https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz > swiftly.tar.gz
tar zxf swiftly.tar.gz
./swiftly init # installs the latest toolchain; reports any missing system packages
swiftly install latest
swift --version
Building on a production server needs gigabytes of disk, a lot of memory (the scratch package’s first debug build of Vapor with Fluent took more than ten minutes on a busy laptop) and leaves a compiler where an attacker would like one. A better flow builds elsewhere and ships a binary:
# in CI, inside the swift:6.x-noble image used by the Dockerfile (same OS release as the server)
swift build -c release --static-swift-stdlib --product App
# copy the binary and the folders the app reads at runtime
rsync -a --delete .build/release/App Public Resources deploy@your_server_ip:/opt/bookshelf/releases/1.4.0/
--static-swift-stdlib links the Swift runtime into the executable, so the server needs no Swift installation, only the C libraries that the binary imports (for a Fluent and Postgres service these are in a stock Ubuntu; the template Dockerfile’s
runtime stage lists the few that may be needed, such as libjemalloc2, ca-certificates and tzdata). Build on the same distribution release as the server. SwiftPM resource bundles (*.resources) must be copied next to the binary.
Run it under systemd
systemd is on practically every server Linux and is the right default supervisor. The service file differs from the one in the official guide in three ways: it uses Type=exec (the unit is "started" only when the binary has been executed), it logs to the journal instead of the deprecated StandardOutput=syslog, and it
sandboxes the process.
[Unit]
Description=Bookshelf (Vapor)
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=exec
User=vapor
Group=vapor
WorkingDirectory=/opt/bookshelf/current
EnvironmentFile=/etc/bookshelf/bookshelf.env
ExecStart=/opt/bookshelf/current/App serve --env production --hostname 127.0.0.1 --port 8080
Restart=on-failure
RestartSec=3
TimeoutStopSec=30
LimitNOFILE=65536
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/bookshelf
[Install]
WantedBy=multi-user.target
Source: Vapor docs: Systemd
Create the unprivileged service account separately from the deploy user, with no sudo rights and no login shell
(useradd --system --home-dir /opt/bookshelf --shell /usr/sbin/nologin vapor). Let deploy own the release
directories and the service only read them (install -d -o deploy -g vapor -m 750 /opt/bookshelf/releases), and keep
the environment file readable only by root and the service user (chown root:vapor /etc/bookshelf/bookshelf.env and
chmod 640 /etc/bookshelf/bookshelf.env). Put placeholders such as the following in it:
# /etc/bookshelf/bookshelf.env
LOG_LEVEL=info
DATABASE_URL=postgres://bookshelf:CHANGE_ME@127.0.0.1:5432/bookshelf?tlsmode=disable
JWT_SIGNING_KEY=CHANGE_ME
sudo ln -sfn /opt/bookshelf/releases/1.4.0 /opt/bookshelf/current # point at the release
sudo -u vapor bash -c 'set -a; . /etc/bookshelf/bookshelf.env; set +a; exec /opt/bookshelf/current/App migrate --yes'
sudo systemctl daemon-reload
sudo systemctl enable --now bookshelf
sudo systemctl restart bookshelf
journalctl -u bookshelf -f
The migration runs as the service user and sources the environment file inside that user’s shell (set -a exports
every variable it defines), so the secrets never appear on a command line, where ps and the shell history would show
them; keep the values free of characters the shell would interpret, or quote them. The ln -sfn step is the whole
"release" mechanism: every version lives in its own directory, current points at one, and a rollback is moving the link back and restarting. restart sends SIGTERM, waits for Vapor’s graceful shutdown (at most TimeoutStopSec) and starts the new binary; for a few seconds
there is no listener, which is the price of a single machine. systemd-analyze verify accepted the unit on systemd 255. A directive worth remembering from the file: ProtectSystem=strict makes the whole file system read-only for the service except ReadWritePaths, so a service that
stores uploads must name that directory there.
Or run it under Supervisor
Supervisor is the older option that the official guide and Condon’s book use. It is still fine, particularly on hosts without systemd (a plain container, some BSDs) or if you already run other programs under it:
[program:bookshelf]
command=/opt/bookshelf/current/App serve --env production --hostname 127.0.0.1 --port 8080
directory=/opt/bookshelf/current
user=vapor
environment=LOG_LEVEL="info",DATABASE_URL="postgres://bookshelf:CHANGE_ME@127.0.0.1:5432/bookshelf"
autostart=true
autorestart=unexpected
stopsignal=TERM
stopwaitsecs=30
stdout_logfile=/var/log/supervisor/%(program_name)s-stdout.log
stderr_logfile=/var/log/supervisor/%(program_name)s-stderr.log
Source: Vapor docs: Supervisor
sudo apt-get install supervisor
sudo cp bookshelf.conf /etc/supervisor/conf.d/
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status bookshelf
Differences from systemd that matter: Supervisor does not read an environment file (variables are one comma-separated environment= line, values quoted when they contain punctuation, so the password above would be a poor fit for the config file; prefer systemd’s EnvironmentFile), it has no sandboxing, and stopwaitsecs is the equivalent of TimeoutStopSec. The official guide uses supervisorctl add hello, which may already start the program; update after reread is the safer sequence.
The configuration file parsed with Supervisor 4.2.5; the real binary was not run under it.
nginx as reverse proxy, static file server and TLS terminator
Vapor can speak HTTP/2 and TLS itself (see the Server Configuration page), but a reverse proxy in front of it earns its place: it terminates TLS with certificates renewed by certbot, serves static files without waking the Swift process, buffers slow clients, caps request bodies, and gives you one place for access logs and rate limits. The official guide recommends proxying behind nginx; its sample configuration needs several corrections for a current system, noted below.
# /etc/nginx/conf.d/bookshelf.conf (nginx 1.25.1 or newer for "http2 on")
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream bookshelf {
server 127.0.0.1:8080;
keepalive 16;
}
server {
listen 80;
listen [::]:80;
server_name bookshelf.example.com;
# ACME http-01 challenges (certbot --webroot) would be served from here before the redirect.
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name bookshelf.example.com;
ssl_certificate /etc/letsencrypt/live/bookshelf.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bookshelf.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000" always;
client_max_body_size 10m;
# Static files straight from the deployed Public/ directory.
root /var/www/bookshelf/Public;
location ~ /\. {
deny all;
}
location / {
try_files $uri @vapor;
}
# WebSockets (see the WebSockets page) need the upgrade headers and a long read timeout.
location /ws/ {
proxy_pass http://bookshelf;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 1h;
}
# Server-sent events must not be buffered.
location /events/ {
proxy_pass http://bookshelf;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 1h;
}
location @vapor {
proxy_pass http://bookshelf;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
}
}
Obtain the certificate with certbot, for example sudo certbot --nginx -d bookshelf.example.com, which writes the ssl_certificate lines and renews the certificate through a timer; the file above shows the result.
What the configuration does, and what was tested. With a Vapor process behind it, curl through nginx returned 200 for /healthz over HTTP/2 with the Strict-Transport-Security header, 301 from port 80 to HTTPS, the CSS file from Public/ directly, 403 for a dotfile placed in Public/, and 413 for an 11 MB body (limit 10 MB).
-
X-Forwarded-For $remote_addr, not$proxy_add_x_forwarded_for. The official sample appends the client address to whatever header the client sent. Vapor’sreq.peerAddresstakes theForwardedorX-Forwarded-Forheader at face value, with no notion of a trusted proxy. Measured against this configuration: a request carryingX-Forwarded-For: 6.6.6.6was reported as192.168.65.1, the real peer, because nginx overwrote the header; with$proxy_add_x_forwarded_forthe same request was reported as6.6.6.6. Any rate limit, audit log or IP allow-list keyed onpeerAddresswould be forgeable. The Server Configuration page explains the limits ofpeerAddressfurther (a list of several addresses does not parse). -
TLS settings. The sample enables
TLSv1andTLSv1.1and an ancient cipher list; both protocols are deprecated (RFC 8996).TLSv1.2 TLSv1.3with nginx’s defaults is the modern choice, so the cipher string is dropped.http2 on;replaceslisten ... http2from nginx 1.25.1. -
Static files.
rootplustry_files $uri @vaporservesPublic/from disk and passes everything else on. nginx runs aswww-data, so it must be able to read the directory; on current Ubuntu a home directory is mode750, which is why the example deploys to/var/www/bookshelfrather than/home/vapor/...as the official guide does. If Vapor itself serves files, enableFileMiddleware(Static Files and File I/O) and drop therootlines; the dotfile rule guards against serving.env-style files that ended up inPublic/. -
WebSockets and server-sent events need their own
locationblocks: the upgrade headers and a longproxy_read_timeoutfor sockets (WebSockets), andproxy_buffering offfor event streams (Server-Sent Events). Without them a connection works for a minute and is then cut, or events arrive in bursts. -
Keep-alive to the upstream. The
upstreamblock withkeepalive 16andproxy_http_version 1.1plus an emptyConnectionheader reuses connections to Vapor instead of opening one per request. -
X-Forwarded-Proto. The header is set above, but Vapor 4.122.2 does not interpret it, so code that needs to know whether the original request was HTTPS (secure cookies, absolute URLs) must read the header itself or use a configured public base URL.
Treat the nginx file as a starting point and read the Security Hardening page for the headers and limits that belong at this layer.
Container platforms
All of the following run the same image. The template’s ENTRYPOINT ["./App"] with CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] means that "run the container" starts the server, and passing a command (migrate --yes, queues) replaces only the CMD.
The template’s runtime stage has no shell tooling beyond Ubuntu’s base and no curl, so container-level health checks that shell out to curl fail; use the platform’s HTTP probes instead.
AWS: ECS on Fargate with RDS
Fargate runs containers without servers to manage. The pieces are an ECR repository, a task definition, a service behind an Application Load Balancer, and an RDS or Aurora Postgres instance in private subnets. The platform pages of this site cover each one (ECS and Fargate, RDS, Container Registry, Load Balancers and Gateways, Secrets Manager and KMS); this is only the Vapor part of the task definition.
{
"family": "bookshelf",
"requiresCompatibilities": ["FARGATE"],
"networkMode": "awsvpc",
"cpu": "512",
"memory": "1024",
"runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
"executionRoleArn": "arn:aws:iam::111122223333:role/bookshelfTaskExecution",
"taskRoleArn": "arn:aws:iam::111122223333:role/bookshelfTask",
"containerDefinitions": [
{
"name": "bookshelf",
"image": "111122223333.dkr.ecr.eu-west-1.amazonaws.com/bookshelf:1.4.0",
"essential": true,
"portMappings": [{ "containerPort": 8080, "protocol": "tcp" }],
"environment": [{ "name": "LOG_LEVEL", "value": "info" }],
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:eu-west-1:111122223333:secret:bookshelf/database-url-AbCdEf"
}
],
"stopTimeout": 30,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/bookshelf",
"awslogs-region": "eu-west-1",
"awslogs-stream-prefix": "app"
}
}
}
]
}
-
The
secretsarray injects a Secrets Manager value as the environment variableDATABASE_URLat task start; the execution role needs permission to read it. Nothing is in the image or the task definition’s plain text. -
stopTimeoutis the grace period betweenSIGTERMandSIGKILLand must exceed Vapor’sshutdownTimeout. Fargate’s default is 30 seconds. -
The target group’s health check should call
/healthzon port 8080; give the service a health check grace period so that slow starts are not killed. -
Run migrations as a one-off task with the same task definition and a command override, and update the service when it succeeds:
aws ecs run-task --cluster bookshelf --task-definition bookshelf --launch-type FARGATE \ --network-configuration "awsvpcConfiguration={subnets=[subnet-0example],securityGroups=[sg-0example]}" \ --overrides '{"containerOverrides":[{"name":"bookshelf","command":["migrate","--yes"]}]}' aws ecs update-service --cluster bookshelf --service bookshelf --task-definition bookshelf -
Build for the CPU architecture of the task (
X86_64above; a Graviton task usesARM64and anarm64image).
Google Cloud Run
Cloud Run scales containers per request, to zero when idle. The container must listen on the port it is told about (--port, default 8080, also exported as PORT), which is exactly the template’s CMD. A request-driven platform suspends CPU outside requests by default, so work that
runs after the response (queue workers, scheduled jobs) belongs in a separate always-on service or a Cloud Run job; see Cloud Run and Cloud Run scaling, traffic and jobs for those settings.
gcloud run deploy bookshelf \
--image europe-west1-docker.pkg.dev/PROJECT/bookshelf/app:1.4.0 \
--region europe-west1 --port 8080 \
--set-secrets DATABASE_URL=bookshelf-database-url:latest
gcloud run jobs create bookshelf-migrate \
--image europe-west1-docker.pkg.dev/PROJECT/bookshelf/app:1.4.0 \
--region europe-west1 --args=migrate,--yes \
--set-secrets DATABASE_URL=bookshelf-database-url:latest
gcloud run jobs execute bookshelf-migrate --region europe-west1 --wait
Cloud Run sends SIGTERM and allows about ten seconds before killing the instance, so keep the server’s shutdownTimeout at or below that. For Cloud SQL, use a private IP address or the Cloud SQL connector over TCP, set as DATABASE_URL; PostgresKit also accepts a Unix-socket URL (postgres+uds://user:password@localhost/path/to/socket#database), which is how a mounted
socket would be addressed, but that form was not tried against Cloud SQL here. The platform page for Cloud SQL is Cloud SQL.
Kubernetes
On Kubernetes the pieces are a Deployment with probes, a Service, a Secret holding DATABASE_URL, and a Job for migrations. The platform side is covered by the Kubernetes section of this site
(Health Probes and Lifecycle, Jobs and CronJobs, Secrets, Ingress); the manifests below show the Vapor-specific choices.
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookshelf
spec:
replicas: 3
selector:
matchLabels:
app: bookshelf
strategy:
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app: bookshelf
spec:
terminationGracePeriodSeconds: 30
containers:
- name: bookshelf
image: registry.example.com/bookshelf:1.4.0
ports:
- name: http
containerPort: 8080
envFrom:
- secretRef:
name: bookshelf-env
env:
- name: LOG_LEVEL
value: info
startupProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 2
failureThreshold: 30
livenessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 10
timeoutSeconds: 2
readinessProbe:
httpGet: { path: /readyz, port: http }
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
resources:
requests: { cpu: 250m, memory: 192Mi }
limits: { memory: 384Mi }
securityContext:
runAsNonRoot: true
runAsUser: 999
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
---
apiVersion: v1
kind: Service
metadata:
name: bookshelf
spec:
selector:
app: bookshelf
ports:
- name: http
port: 80
targetPort: http
-
Probes. Liveness hits
/healthz(process only), readiness hits/readyz(database), and a startup probe keeps the liveness probe from killing a slow starter.maxUnavailable: 0withmaxSurge: 1replaces pods only after the new one is ready. -
Termination. On deletion the pod receives
SIGTERM; Vapor stops accepting connections and drains for up to itsshutdownTimeout, well insideterminationGracePeriodSeconds: 30. Endpoint removal and the signal race each other, so a few requests may still arrive during shutdown; a shortpreStopsleep is the usual, optional mitigation. -
Non-root. The template image sets
USER vapor:vapor, a named user. A pod that asks forrunAsNonRoot: truecannot verify a named user and fails to start, so set the numeric id too: theuseradd --systemin the template’s runtime stage gives thevaporuser id 999 onubuntu:noble(checked in a container; confirm withid vaporon your own base image) and thenrunAsUser: 999is added to the container’ssecurityContext. -
Resources. A memory limit with a request of the same order avoids OOM kills at start-up; Vapor’s footprint is small, so the values above are a starting point to be tuned under load (Production and Performance).
-
Create the secret outside the repository, for example
kubectl create secret generic bookshelf-env --from-literal=DATABASE_URL=..., or let an external-secrets operator populate it.
The migration Job runs the same image with different arguments; the image’s ENTRYPOINT stays ./App, so args alone is enough:
apiVersion: batch/v1
kind: Job
metadata:
name: bookshelf-migrate-2026-10-17
spec:
backoffLimit: 2
ttlSecondsAfterFinished: 3600
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: registry.example.com/bookshelf:1.4.0
args: ["migrate", "--yes"]
envFrom:
- secretRef:
name: bookshelf-env
Apply the Job and wait for it before rolling the Deployment (kubectl wait --for=condition=complete job/<name> --timeout=300s), or declare it as a pre-upgrade hook in your Helm chart. Give each release a new Job name or let ttlSecondsAfterFinished clean the old one up, because a Job’s pod template cannot be updated in place. A QueueWorker or scheduled-jobs process
(Scheduled Jobs) is a second Deployment of the same image with args: ["queues"] or ["queues", "--scheduled"], not a sidecar of the web pods, so that web replicas can scale without duplicating scheduled work.
The YAML files parsed correctly; they were not applied to a cluster in this scratch work.
Azure Container Apps
The same image runs on Azure Container Apps with the same environment variables and probes; the site’s Container Apps page covers the platform. Nothing in the Vapor service changes.
Swift on AWS Lambda
Lambda runs a function per event and bills per invocation, which is a different model from a Vapor server that accepts connections for days. There are three honest options:
-
Write the function natively. The Swift AWS Lambda Runtime (version 3.0.2,
awslabs/swift-aws-lambda-runtime) implements the Lambda Runtime API in Swift on SwiftNIO and ships a SwiftPM plugin that builds on Amazon Linux in Docker and deploys. A handler is a small closure over a Codable event; it can share model and validation code with Bookshelf through a SwiftPM library target, but it is not a Vapor application. This is the right choice for event handlers (an S3 upload processor, a scheduled job) and for new HTTP endpoints behind API Gateway or a function URL that do not need the Vapor stack. -
Run the container behind an adapter. A container image of the Vapor service can run on Lambda behind a generic web adapter that translates invocations into HTTP calls to a local port. It works, but cold starts, one-request-at-a-time concurrency and database connection management (every execution environment opens its own connections, so a connection proxy such as RDS Proxy is usually needed) make it a poor default. Not tried here.
-
Use the community adapters for Vapor. Several small projects bridge Vapor and Lambda (for example
swift-vapor-lambda). The ones found were last updated years ago and none is maintained by the Vapor team; check their activity and your Vapor version before depending on one.
See AWS Lambda for packaging, permissions and event sources, and Swift Package Manager for the plugin mechanism. If the service is request/response with ordinary traffic, Fargate or Cloud Run give you the same Vapor code with fewer constraints.
History: Heroku, Vapor Cloud and IBM Cloud
Older guides and books lead with platforms that have changed or gone:
-
Heroku. The Vapor documentation still describes the community buildpack route:
heroku buildpacks:set vapor/vapor, a.swift-versionfile (the page’s example says5.8.1) and aProcfilesuch asweb: App serve --env production --hostname 0.0.0.0 --port $PORT, with the add-on database’sDATABASE_URLread at runtime. Heroku ended its free dynos and free Postgres in November 2022, so every Heroku tutorial that says "free" is out of date. Thevapor/heroku-buildpackrepository’s last push is from June 2025, and a buildpack that compiles Swift on Heroku’s build machines is slow and tied to Heroku’s stacks; today a Docker image deployed to Heroku (container stack) or, more usually, to one of the platforms above is the lower-risk path. The$PORTandDATABASE_URLconventions are the part that lives on: they are why theconfigureDatabasefunction above readsDATABASE_URL. This route was not run here. -
Vapor Cloud. A hosted platform from Vapor that deployed with
vapor cloud deploy(Yeung, chapter 12). The service was shut down. Nothing replaces it inside Vapor; Fly.io is the closest in spirit. -
IBM Cloud. Patel’s chapter 9 deploys to IBM Cloud, a path inherited from the Kitura era; Kitura is no longer maintained and Vapor’s documentation does not cover IBM Cloud. The container recipes above apply to any provider.
Condon’s chapters 31 to 34 (Heroku, Docker and Compose, EC2 with RDS, nginx, systemd and Supervisor, horizontal scaling) remain a good map of the problem space. What is out of date are the tools: the swift:4.2 and Ubuntu 16.04 images, the Vapor 3 toolbox commands, Swift installed by hand-picked tarball instead of Swiftly or an official image, and TLS settings from the nginx page that allow protocols
deprecated since 2021.
Common errors
| Symptom | Cause and fix |
|---|---|
The service runs but the load balancer or Fly proxy cannot reach it |
It is bound to |
The process exits at start-up with a bad-URL error |
|
Two instances run migrations at the same time |
Migrations were wired into start-up ( |
|
The |
Kubernetes shows |
|
Rolling update drops requests |
The process does not receive |
nginx returns |
Vapor is down or still starting: check |
nginx returns |
The |
Every client appears as the same address, or as an attacker-chosen one |
|
Event streams or WebSockets stall behind the proxy |
Buffering and the default 60-second read timeout. Use the dedicated |
Where the books are outdated
Condon et al. (Server-Side Swift with Vapor, chapters 31 to 34) target Vapor 3 on Swift 4 and 5: the Heroku .swift-version buildpack flow, swift:4.2 and Ubuntu 16.04 Docker images, supervisorctl and nginx recipes that predate TLS 1.3. Yeung (chapter 12) leads with Vapor Cloud, which no longer exists, and Patel (chapter 9) with Heroku and IBM Cloud. The current replacements are the official
multi-stage Docker image, Fly.io or a container platform, and for a single server a systemd unit and nginx as above. For X-Forwarded-For, the Kubernetes probes and the migration-once rule the books say nothing, and those are where production incidents come from.