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
exportin.zshrc. Database connection strings and similar config need to go into the plist'sEnvironmentVariablesdictionary, or be loaded explicitly from a separate.envfile in code. - Port conflicts that don't surface as errors: if
launchctl loadreports "service already exists" but you still can't reach the port, it's usually a stale plist entry left over from a previous crash. Runlaunchctl removefirst, thenloadagain. - KeepAlive restart loops: a typo in a config path can make the process die instantly on startup, and
KeepAlivewill restart it endlessly, spiking CPU usage. Runlaunchctl unloadfirst to stop the bleeding, then investigate. - No rollback point in the release script: point
currentat a symlink targeting a specificreleases/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.