Simplifying Atomic Deploys for Critical Side Projects with systemd Portable Services
Share this article
For developers managing critical side projects, deployment complexity can turn occasional updates into high-stress ordeals. Rukul Kulkarni, creator of bowl.science, faced this firsthand. His platform hosts U.S. Department of Energy Science Bowl competitions—a production environment where failures aren’t tolerated, yet updates happen sporadically during his limited spare time. His stack? A Go server with SQLite, Litestream for replication, and Let’s Encrypt TLS—all on a single Debian VPS.
The challenge wasn’t the workload but the deployment fragility. Manual SSH updates, unversioned systemd configurations, and no atomic rollbacks meant every change risked breaking a live tournament. Kulkarni needed:
- Version-controlled deployment artifacts
- Single-command atomic updates
- Service coordination (Litestream restore before app startup)
- Zero-downtime restarts
Why Containers Fell Short
Container solutions like Docker Compose or s6-overlay seemed promising initially but introduced mismatched abstractions:
- **ECS/Fleet Orchestrators**: Overkill for a static VPS, adding slow pull-based deploys and network hops.
- **Docker Compose**: Duplicated the host’s init system while forcing unnecessary containerization of static Go binaries.
- **s6**: Mandated full restarts, triggering costly Litestream snapbacks for minor app fixes.
As Kulkarni noted: "Wrapping a binary in a container to use a process manager on a system that already has one felt like ending up back at 'run this binary' with extra steps."
Enter systemd Portable Services
systemd’s portable services offered a minimalist alternative. These are lightweight OS images containing only app essentials—binaries, unit files, and configs—mounted directly by the host’s init system. Key components:
├── usr/
│ ├── bin/bowlscience-server # Go binary
│ ├── bin/litestream # Replication tool
│ └── lib/systemd/system/ # Unit files for app & Litestream
├── etc/ # Host-bind configs
└── var/lib/bowlscience/ # Writable state directory
Deployment uses mksquashfs to build an 18MB image. Updates are atomic: portablectl reattach swaps images seamlessly, and services restart independently—no Litestream disruption for app patches.
Zero-Downtime Magic
Socket activation solved a critical pain point. systemd binds ports 80/443 pre-boot, passing descriptors to the unprivileged Go app:
# bowlscience-http.socket
[Socket]
ListenStream=80
FileDescriptorName=http
Service=bowlscience.service
This eliminated connection-refused errors during restarts. As Kulkarni explained: "Requests queue in-kernel while the new process spins up—no load balancer needed for a single server."
Security improved passively:
- PrivateUsers=yes sandboxes privileges
- ProtectSystem=strict enforces read-only roots
- Socket activation avoids root runtime requirements
The Payoff
Deploys now take one command (make deploy), with configurations versioned in Git. The 600-line custom Python orchestrator was retired. For infrequently updated projects, portable services deliver:
- Atomicity: No partial-state deploys
- Simplicity: No artifact registries or container overhead
- Resilience: Near-zero downtime during updates
As Kulkarni concluded: "I accepted 5-second blips as normal—socket activation made them disappear by accident." For solo developers juggling production-grade side projects, systemd proves that sometimes the best abstraction is none at all.
Source: Rukul Kulkarni