Deploying Swift Vapor on a Cloud Mac Mini: launchd Daemons and Zero-Downtime Releases

DevOps & CI/CD ·~6 min read

Deploying Swift Vapor on a Cloud Mac Mini: launchd Daemons and Zero-Downtime Releases

Last month one of our MacBook Pros ended up serving as an "improvised backend": it was running a Swift Vapor service used for local API testing against an iOS app, started with swift run, and nobody dared close the terminal window once it was up. Then a routine system update triggered an automatic restart, and the service quietly died for most of a day — QA thought their own network was broken when they couldn't reach the endpoints. That "babysit the process by hand" approach had to go once we moved to a cloud Mac mini that stays online around the clock. This post walks through moving a Vapor service off an interactive terminal session and onto a proper launchd daemon, then taking it further into zero-downtime releases.

Why run Vapor on a cloud Mac mini

Swift Vapor is often used as a lightweight companion backend for iOS clients: mock login endpoints, push notification callbacks, analytics ingestion, internal admin panels. These services aren't large, but they need to run 24/7, and they often need a real macOS environment — some setups have to work against APNs certificates or compile native dependencies via swift build. A cloud Mac mini gives you a dedicated physical machine rather than a VM, which fills exactly the gap between "a laptop can't stay on all the time" and "cloud Linux can't run macOS-only dependencies." But once you own a bare-metal box outright, process management becomes entirely your responsibility — there's no platform layer automatically restarting a crashed process for you. You have to build that with launchd yourself.

Environment setup: toolchain and port layout

After logging in, confirm the toolchain version first, so differences between your local dev machine and the cloud instance don't cause subtly different build behavior:

swift --version
xcode-select -p
mkdir -p ~/apps/vapor-api/releases
mkdir -p ~/apps/vapor-api/logs

When planning ports, reserve two of them up front so you're ready for the zero-downtime switch later:

Purpose Port Notes
Production main port 8080 The currently live version, proxied by Nginx
Canary/new build port 8081 New builds self-check here before going live
Internal health check 8080/8081 /healthz Custom Vapor route returning the build version

It's worth having /healthz return the short Git commit hash directly — during a release it's an easy visual confirmation that traffic actually landed on the right version, instead of guessing whether the process really restarted.

Daemonizing with launchd instead of nohup

Write ~/Library/LaunchAgents/com.m4rent.vaporapi.plist like this:

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.m4rent.vaporapi</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Users/deploy/apps/vapor-api/current/Run</string>
    <string>serve</string>
    <string>--hostname</string>
    <string>127.0.0.1</string>
    <string>--port</string>
    <string>8080</string>
  </array>
  <key>WorkingDirectory</key>
  <string>/Users/deploy/apps/vapor-api/current</string>
  <key>KeepAlive</key>
  <true/>
  <key>RunAtLoad</key>
  <true/>
  <key>StandardOutPath</key>
  <string>/Users/deploy/apps/vapor-api/logs/stdout.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/deploy/apps/vapor-api/logs/stderr.log</string>
</dict>
</plist>

Load it and verify:

launchctl load ~/Library/LaunchAgents/com.m4rent.vaporapi.plist
launchctl list | grep vaporapi
curl -s localhost:8080/healthz

Key plist fields explained

With KeepAlive set to true, launchd automatically restarts the process if it exits unexpectedly — no extra watchdog script needed. But be careful: if the config itself is wrong and the process crashes immediately on startup, KeepAlive will trap it in a restart loop every second. In that situation, checking the restart count in launchctl list will pinpoint the problem faster than scrolling through logs. RunAtLoad guarantees the service comes back up automatically on boot or login, which pairs well with a cloud Mac mini's no-scheduled-downtime uptime model — you basically never have to manually kick off the startup sequence again. Always set WorkingDirectory explicitly; otherwise relative paths for config files and logs will resolve against whatever launchd's default working directory happens to be, which is rarely where you expect.

Zero-downtime releases: dual ports plus Nginx reverse proxy switching

Don't just overwrite the current directory and restart the same process on release day — that guarantees a few seconds of dead air. Instead, start the new build on a separate port, confirm it's healthy, and only then flip traffic over:

cp -R releases/build-2026-07-12 releases/build-2026-07-12-verify
sed -i '' 's/8080/8081/' com.m4rent.vaporapi-staging.plist
launchctl load com.m4rent.vaporapi-staging.plist
curl -s localhost:8081/healthz

Once /healthz returns the expected commit hash, switch the Nginx upstream:

upstream vapor_api {
    server 127.0.0.1:8081;
}

nginx -s reload only reloads the configuration — it never drops already-established connections. Let the old process on 8080 finish handling its remaining in-flight requests, then take it down gracefully with launchctl unload.

Never shut down the old port before you've cut traffic over. A passing health check doesn't guarantee the new version holds up under real load — leave yourself at least a few minutes of observation, since flipping the upstream back with one command is far faster than rolling back after the fact.

Logging and monitoring: keeping the disk from filling up

The stdout.log and stderr.log files launchd redirects to aren't rotated automatically — a long-running service can accumulate several gigabytes within a few months. Add a rule using the system's built-in newsyslog:

/Users/deploy/apps/vapor-api/logs/stdout.log  deploy:staff  644  7  10240  *  N

This rotates the log once it exceeds 10MB, keeping up to 7 historical files. Storage on a cloud Mac mini's SSD is fixed, and runaway logs directly eat into the space you need for build artifacts and snapshots — it's worth running du -sh ~/apps/vapor-api/logs weekly just to keep an eye on the growth trend.

Lessons learned the hard way

  • Missing environment variables: processes launched by launchd don't inherit anything you export in .zshrc. Database connection strings and similar config need to go into the plist's EnvironmentVariables dictionary, or be loaded explicitly from a separate .env file in code.
  • Port conflicts that don't surface as errors: if launchctl load reports "service already exists" but you still can't reach the port, it's usually a stale plist entry left over from a previous crash. Run launchctl remove first, then load again.
  • KeepAlive restart loops: a typo in a config path can make the process die instantly on startup, and KeepAlive will restart it endlessly, spiking CPU usage. Run launchctl unload first to stop the bleeding, then investigate.
  • No rollback point in the release script: point current at a symlink targeting a specific releases/build-* directory. Rolling back then just means repointing the symlink to the previous build and reloading — avoid overwrite-in-place releases entirely.

Frequently asked questions

Why not just run the Vapor binary with nohup or in a screen session?

nohup only detaches the process from the terminal; it won't restart after a crash or reboot. launchd is macOS's system-level supervisor: it can auto-restart on crash via KeepAlive and start the service on boot, which matters for a machine you're renting long-term.

Do I really need two ports for zero-downtime deploys?

Restarting on the same port always leaves a gap where connections fail while the new process boots. Running the new build on a second port, health-checking it, then flipping the Nginx upstream lets old connections drain while new traffic goes to the healthy instance.

Should the database run on the same Mac mini as the app?

For small workloads, a local PostgreSQL or SQLite instance with daily snapshots is fine. For larger datasets or multi-instance setups, point to a separate managed database so the Mac mini only handles the application layer.

Try it on a dedicated Mac mini

Rent by the day, with root access and delivery in minutes — perfect for testing before committing to a longer term.

Order now