Running a Vapor or Hummingbird service on Ubuntu

Install Swift with swiftly, build Vapor or Hummingbird in release mode, and run it under systemd behind Nginx with TLS and zero-downtime releases.

Jacob Søndergaard ·

A Vapor or Hummingbird service compiles to one binary. Deploying it to an Ubuntu server takes five things: a Swift toolchain from swift.org, enough memory to build, a systemd unit, Nginx with TLS in front, and a way to replace the binary without dropping requests. This post sets up all five for both frameworks. Every command was run on a fresh Ubuntu 26.04 server, and the toolchain and both builds were checked on Ubuntu 24.04 as well.

The examples use a Hummingbird app generated from its 2.x template and a Vapor app from vapor new, both on Swift 6.4.1. Commands run as root unless a step says otherwise. The server should already have a deploy user with SSH keys and a firewall; if not, harden it first.

Install the Swift toolchain with swiftly

swift.org recommends swiftly for installing Swift on Linux. It downloads toolchains from swift.org, checks each one’s PGP signature before unpacking it, and tells you which system packages the toolchain needs. It installs into the home directory of whoever runs it, so run it as the account that will build, not as root:

As root
adduser --disabled-password --comment "" deploy
sudo -iu deploy
As deploy
curl -fsSLO https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz
curl -fsSLO https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz.sig
curl -fsSL --compressed https://swift.org/keys/all-keys.asc | gpg --import -
gpg --verify swiftly-$(uname -m).tar.gz.sig swiftly-$(uname -m).tar.gz

tar zxf swiftly-$(uname -m).tar.gz
./swiftly init --assume-yes --skip-install --quiet-shell-followup
. ~/.local/share/swiftly/env.sh

swiftly install 6.4.1 --use --assume-yes --post-install-file=post-install.sh
cat post-install.sh
exit

The first four lines check swiftly itself, since nothing has checked it yet. all-keys.asc holds swift.org’s release and automatic signing keys; --compressed lets curl decode the file if the CDN sends it gzip-encoded. The line that matters in gpg’s output is Good signature:

gpg: Good signature from "Swift Automatic Signing Key #4 <swift-infrastructure@forums.swift.org>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: E813 C892 820A 6FA1 3755  B268 F167 DF1A CF9C E069

The warning means you have not personally certified that key, which is expected. The check proves the archive was signed by a key in the file you imported, and both came from swift.org over HTTPS. If you want to defend against swift.org’s key file itself being replaced, write down the fingerprints once and compare against them on later installs.

swiftly install verifies the toolchain’s signature the same way. The post-install.sh it writes is an apt-get install line for the libraries the toolchain links against, and it differs per release: on 26.04 it names libgcc-15-dev and libstdc++-15-dev, on 24.04 the -13 versions. Run that file rather than a package list copied from an older guide. Back as root:

As root
apt-get update
bash /home/deploy/post-install.sh
sudo -iu deploy swift --version

Without swiftly

If you would rather have one toolchain for the whole machine under /opt, download the tarball and its signature yourself. The URL is built from the release and the CPU architecture:

As root
cd "$(mktemp -d -p /var/tmp)"     # /tmp is in RAM on 26.04
V=6.4.1
. /etc/os-release
ARCH=$(uname -m); SUFFIX=""; [ "$ARCH" = aarch64 ] && SUFFIX="-aarch64"
DIR="ubuntu${VERSION_ID/./}$SUFFIX"
FILE="swift-$V-RELEASE-ubuntu$VERSION_ID$SUFFIX.tar.gz"
URL="https://download.swift.org/swift-$V-release/$DIR/swift-$V-RELEASE/$FILE"

curl -fLO "$URL"
curl -fLO "$URL.sig"
curl -fsSL --compressed https://swift.org/keys/all-keys.asc | gpg --import -
gpg --verify "$FILE.sig" "$FILE"

install -d /opt/swift/$V
tar -xzf "$FILE" -C /opt/swift/$V --strip-components=1
ln -sfn /opt/swift/$V /opt/swift/current
/opt/swift/current/usr/bin/swift --version

Keep the -f on curl. A version swift.org never published does not return a 404 directly: it redirects to swift.org’s 404 page, and without -f curl saves that HTML page under the tarball’s name. The download goes to /var/tmp because /tmp on 26.04 is a tmpfs held in memory, sized at half the RAM, and the toolchain archive is about 1 GB. The system packages are the same ones swiftly’s post-install.sh lists.

Ubuntu 26.04 starts at Swift 6.4.0

swift.org builds Ubuntu 26.04 toolchains from Swift 6.4.0 onwards. 6.3.3 and everything before it exist for 22.04 and 24.04 only, and asking for one on 26.04 fails at the download:

$ swiftly install 6.3.3
Installing Swift 6.3.3
Error: Swift 6.3.3 does not exist at URL https://download.swift.org/swift-6.3.3-release/ubuntu2604-aarch64/swift-6.3.3-RELEASE/swift-6.3.3-RELEASE-ubuntu26.04-aarch64.tar.gz, exiting

If your package pins an older Swift in .swift-version and cannot move yet, use Ubuntu 24.04, which takes toolchains back to 5.10.1. An Ubuntu server does not change release in place, so decide before you create it.

The second trap is the name. swift.org published every earlier x.y.0 under two components, 6.3 and 6.2, and published 6.4.0 as 6.4.0. A download URL with swift-6.4-release leads to the 404 page. swiftly reads 6.4 differently, as the newest 6.4 release, and installs 6.4.1. Anything that builds URLs from a version string, including your own scripts, needs the full 6.4.0.

Building on the server: 4 GB including swap

swift build -c release compiles every dependency with optimisation, several at a time, and memory use grows with the number of jobs. The Vapor template built on a 2 GB, two-core VM without swap, and peaked near 2 GB on four cores. An application with Fluent, Leaf and a few more packages needs more, and running out is quiet. With memory capped at 600 MB, the build output stopped partway through with no error line, and only the kernel log said why:

$ journalctl -k | grep -i 'killed process'
kernel: Memory cgroup out of memory: Killed process 12456 (swift-build) total-vm:2506012kB, anon-rss:346136kB, ...

Give the server 4 GB of memory including swap. Ubuntu cloud images ship without swap, so add a file:

fallocate -l 4G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
swapon --show

On a machine that is still short, swift build -c release -j 1 runs one job at a time, which is slower and uses less memory.

Build on the server, or somewhere else

Building on the server is the simplest arrangement. The binary is linked against the libraries it will run with, and nothing has to be copied. If the checkout and its .build directory stay in place between deploys, later builds are incremental: the Hummingbird app took 49 seconds cold and under 3 seconds after a one-line change. The Vapor template took about two minutes cold on four cores. The cost is that every deploy spends that CPU and memory on the machine that is serving traffic.

To build elsewhere, build on the same Ubuntu release, in CI or a container, and copy the binary over. Or use the Static Linux SDK, which produces a fully static binary that runs on any Linux. It installs into the toolchain you already have. It unpacks to about 1.1 GB through the temporary directory, hence TMPDIR:

As deploy, in the package
U=https://download.swift.org/swift-6.4.1-release/static-sdk/swift-6.4.1-RELEASE/swift-6.4.1-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz
curl -fsSLO "$U"
curl -fsSLO "$U.sig"
gpg --verify swift-6.4.1-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz.sig swift-6.4.1-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz
TMPDIR=/var/tmp swift sdk install ./swift-6.4.1-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz

swift build -c release --swift-sdk aarch64-swift-linux-musl     # x86_64-swift-linux-musl for Intel and AMD
ldd "$(swift build -c release --swift-sdk aarch64-swift-linux-musl --show-bin-path)/App"

ldd answers not a dynamic executable. Two details catch people whichever way they build:

  • Link the Swift runtime statically. A default build loads libswiftCore.so and friends from the toolchain, and ldd shows them inside /home/deploy/.local/share/swiftly. Ubuntu creates home directories with mode 750, so a service account cannot read them and the binary cannot start. Build with --static-swift-stdlib and the binary needs only libc, libstdc++ and friends from Ubuntu.
  • Ask SwiftPM where the binary is. Swift 6.4 builds into .build/out/Products/..., and a cross-compiled binary is not under .build/release at all. --show-bin-path prints the directory for the flags you pass. Resource bundles (*.resources) sit in the same directory and have to travel with the binary.

A systemd unit for Vapor or Hummingbird

Install Nginx and git, and create the service’s own system account, which owns nothing and cannot log in. Releases live in /srv/hello/releases, and the secrets in a file only root can read:

apt-get install -y nginx git
adduser --system --group --no-create-home --home /nonexistent hello
install -d -m 755 /srv/hello /srv/hello/releases /srv/hello/ports
install -d -m 700 /etc/hello
install -m 600 /dev/null /etc/hello/env

The unit is a template whose instance name is the port, so hello@8081 and hello@8082 can run side by side during a deploy. Each instance runs the release that /srv/hello/ports/<port> points at:

/etc/systemd/system/hello@.service
[Unit]
Description=hello on port %i
After=network.target

[Service]
Type=simple
User=hello
Group=hello
WorkingDirectory=/srv/hello/ports/%i
EnvironmentFile=/etc/hello/env
ExecStart=/srv/hello/ports/%i/server --http-host 127.0.0.1 --http-port %i
Restart=on-failure
RestartSec=3
TimeoutStopSec=30
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target
  • EnvironmentFile. systemd reads it as root before it switches to hello, so the file can stay mode 600 and the service account never reads it from disk.
  • Restart=on-failure. A crash is restarted after three seconds. A clean stop, which is what the deploy script does to the old instance, is left alone.
  • TimeoutStopSec=30. Both frameworks finish in-flight requests on SIGTERM. This is how long systemd waits before it sends SIGKILL.
  • ProtectSystem=strict. The whole filesystem is read-only to the service. If your app writes files, add a ReadWritePaths= line for that directory.

The flags differ by framework. The Hummingbird 2 template reads its address through swift-configuration: --http-host and --http-port, or HTTP_HOST and HTTP_PORT in the environment. Give it --hostname and --port, as older guides do, and it ignores them without an error and listens on 127.0.0.1:8080. Vapor takes its address on the serve command, so for Vapor the line is:

ExecStart=/srv/hello/ports/%i/server serve --env production --hostname 127.0.0.1 --port %i

Bind to 127.0.0.1 in both cases. Only Nginx should reach the app; a service listening on every interface is one firewall mistake away from being public without TLS.

Environment and secrets

Put configuration in /etc/hello/env, one variable per line:

/etc/hello/env
LOG_LEVEL=info
DATABASE_URL="postgres://hello:change-me@127.0.0.1:5432/hello"

systemd removes the double quotes, so a value with spaces or URL punctuation goes in quotes. Vapor reads .env and .env.production from the working directory, and the Hummingbird template reads .env; both still work, but a file in a release directory is replaced on every deploy and tends to end up in git. The file under /etc survives deploys and is readable by root alone. After editing it, restart the live instance: systemctl restart hello@8081, or whichever port is live.

Nginx in front, TLS with certbot

Nginx terminates TLS and forwards to whichever port is live. The upstream’s address lives in its own one-line file, which the deploy script rewrites:

/etc/nginx/sites-available/hello
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

upstream hello {
    include /etc/nginx/hello-upstream.conf;
}

server {
    listen 80;
    listen [::]:80;
    server_name hello.example.com;

    location / {
        proxy_pass http://hello;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}
echo 'server 127.0.0.1:8081;' > /etc/nginx/hello-upstream.conf
ln -s /etc/nginx/sites-available/hello /etc/nginx/sites-enabled/hello
nginx -t && systemctl reload nginx

The map and the two Upgrade lines pass WebSocket upgrades through; without them a WebSocket route works locally and fails behind Nginx. X-Forwarded-Proto tells the app the client used HTTPS, which it needs for redirects and secure cookies once TLS is on. With DNS pointing at the server and ports 80 and 443 open, certbot adds the certificate to this server block and renews it:

apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d hello.example.com

The details, including the renewal timer and the mistakes that break it, are in Let’s Encrypt with Nginx on Ubuntu.

Zero-downtime releases for a compiled binary

Replacing the binary and running systemctl restart leaves a moment where nothing listens on the port. It is short, and requests still land in it: with a client sending requests back to back through Nginx, one restart of the Hummingbird app produced six 502 responses.

The fix is to never restart the instance that is serving. Build the new release, start it on the idle port, wait until it answers, point Nginx at it with a reload (which lets open connections finish), and only then stop the old instance:

/usr/local/bin/deploy-hello
#!/usr/bin/env bash
# Build the latest commit, start it on the idle port, move Nginx to it, stop the old one.
# Usage: deploy-hello            build and activate the latest commit
#        deploy-hello <release>  activate an existing release again (rollback)
set -euo pipefail

SRC=/home/deploy/hello        # git checkout owned by deploy; .build is kept between deploys
PRODUCT=App                   # the executable product in Package.swift
ROOT=/srv/hello
UPSTREAM=/etc/nginx/hello-upstream.conf
HEALTH=/                      # a path that answers 200 once the app is ready
PORTS=(8081 8082)

as_deploy() {
  runuser -u deploy -- env HOME=/home/deploy bash -c \
    '. "$HOME/.local/share/swiftly/env.sh" && cd "$0" && exec "$@"' "$SRC" "$@"
}

if [ $# -eq 0 ]; then
  release=$(date -u +%Y%m%d%H%M%S)
  dir=$ROOT/releases/$release
  as_deploy git pull --ff-only
  as_deploy swift build -c release --static-swift-stdlib
  bin=$(as_deploy swift build -c release --static-swift-stdlib --show-bin-path)
  install -d -m 755 "$dir"
  install -m 755 "$bin/$PRODUCT" "$dir/server"
  # SwiftPM resource bundles sit beside the binary; Vapor serves Public/ from the working directory.
  for extra in "$bin"/*.resources "$SRC/Public" "$SRC/Resources"; do
    if [ -e "$extra" ]; then cp -R "$extra" "$dir/"; fi
  done
else
  release=$1
  dir=$ROOT/releases/$release
  [ -x "$dir/server" ] || { echo "no release $release" >&2; exit 1; }
fi

live=$(grep -oE '127\.0\.0\.1:[0-9]+' "$UPSTREAM" | cut -d: -f2 || true)
next=${PORTS[0]}
if [ "$live" = "$next" ]; then next=${PORTS[1]}; fi

ln -sfn "$dir" "$ROOT/ports/$next"
systemctl restart "hello@$next"

for attempt in $(seq 1 30); do
  if curl -fs -o /dev/null "http://127.0.0.1:$next$HEALTH"; then break; fi
  if [ "$attempt" -eq 30 ]; then
    echo "release $release did not answer on port $next; port $live keeps serving" >&2
    systemctl stop "hello@$next"
    exit 1
  fi
  sleep 1
done

echo "server 127.0.0.1:$next;" > "$UPSTREAM.next"
mv "$UPSTREAM.next" "$UPSTREAM"
nginx -t -q
systemctl reload nginx
systemctl enable -q "hello@$next"

if [ -n "$live" ] && [ "$live" != "$next" ]; then
  sleep 5   # requests already proxied to the old port finish before it stops
  systemctl disable -q --now "hello@$live"
fi

# Keep the five newest releases, and never one a port still points at.
keep=$(readlink -f "$ROOT"/ports/* || true)
ls -1dt "$ROOT"/releases/* | tail -n +6 | while read -r old; do
  grep -qxF "$old" <<<"$keep" || rm -rf "$old"
done
echo "release $release is live on port $next"

The script runs as root so it can manage units and Nginx, and drops to deploy for git and the build, which is the account with the toolchain and the SSH key for your repository. A sudoers rule lets deploy run this script and nothing else, so a CI job that logs in as deploy can deploy without broader root access. Install it and run the first deploy:

install -m 755 deploy-hello /usr/local/bin/deploy-hello
echo 'deploy ALL=(root) NOPASSWD: /usr/local/bin/deploy-hello' > /etc/sudoers.d/deploy-hello
chmod 440 /etc/sudoers.d/deploy-hello
visudo -c

sudo -iu deploy git clone https://github.com/you/hello.git /home/deploy/hello
sudo -iu deploy sudo deploy-hello

For the Vapor app, the only change to the script is the product name, the executableTarget in its Package.swift:

PRODUCT=HelloVapor

The same client, sending requests through Nginx while each approach ran:

plain systemctl restart:   2753 × 200, 6 × 502
deploy-hello:              6689 × 200

A release that crashes on start never receives traffic. The script waits up to 30 seconds for it to answer on the idle port, then stops it and exits, and the old instance keeps serving. Day to day:

systemctl status 'hello@*'
journalctl -u 'hello@*' -f
sudo deploy-hello 20261021114210   # put an earlier release back on the idle port

For a few seconds during each switch, both releases are running. A database migration that the old release cannot work with will break it for those seconds, so make schema changes in a form both versions accept, and remove the old shape in a later deploy.

Node services work the same way

The two-port switch does not depend on Swift. The Node.js post uses a plain restart and measures its gap; this script’s activation half applies to it unchanged.

How Provisore runs Swift sites

On a Swift site, Provisore installs the toolchain from swift.org on the server the first time a site needs it, checks it against pinned signing-key fingerprints, and keeps it under /opt/provisore/swift/<version> for every site on the box. A .swift-version in the repository picks the version, and the default is Swift 6.4.0. It refuses a toolchain older than 6.4.0 on Ubuntu 26.04, stops before the build when the server has less than about 4 GB including swap, and keeps each site’s build cache between deploys. The binary runs under a systemd unit as the site’s own user, behind Nginx with a renewing Let’s Encrypt certificate. Activation is a symlink swap behind a health check that restarts the service in place, and earlier releases stay on disk for rollback. The runtime docs list the details.

Run it on a server you own

Provisore provisions the server, deploys on push and keeps watch, on your own provider account.