Nova Notes

Moving my side projects from Compose to systemd units

· Clara Lehto · 7 min read

For years every side project I ran lived in its own docker-compose.yml. It worked, but a small VPS was spending more memory on the container runtime than on my actual applications. Most of these projects are a single Go or Python binary plus SQLite, so this spring I tried running them as plain systemd services.

A unit file is shorter than you think

[Unit]
Description=Bookmarks service
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/opt/bookmarks/bookmarks --listen 127.0.0.1:8081
WorkingDirectory=/var/lib/bookmarks
DynamicUser=yes
StateDirectory=bookmarks
Restart=on-failure

[Install]
WantedBy=multi-user.target

DynamicUser creates a throwaway user at start, and StateDirectory gives it a persistent, correctly owned directory under /var/lib. That covers most of what I used containers for.

Sandboxing for free

systemd has a surprisingly complete set of isolation options. These lines make the service read-only except for its state directory and hide other users' processes:

ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
NoNewPrivileges=yes
ProtectProc=invisible

Run systemd-analyze security bookmarks.service to get a score and a list of further suggestions.

Logs in one place

Everything goes to journald, so journalctl -u bookmarks -f replaces docker compose logs, and log rotation is handled for me. Boot time on the VPS dropped from about 40 to 12 seconds.

What I would do differently

  • Keep Compose for anything that needs PostgreSQL or Redis next to it — reproducing those setups by hand is not worth it.
  • Build static binaries in CI; copying one file is the whole deployment.
  • Use systemd-run --scope for one-off jobs instead of cron when you want resource limits.

Containers are still the right tool for many things. For a handful of tiny personal services, the init system I already had turned out to be enough.