All notes

Run scheduled Docker jobs with systemd user timers instead of cron

DockersystemdLinuxSelf-hosting

Each of Rubric’s ten scheduled jobs runs as a fresh Docker container that a systemd timer starts under my user account, so it needs no root, and the container exits when the job is done. The jobs poll the sources every 30 minutes, build the edition overnight, publish it at 05:50 UTC and post about it later in the day. Since this setup went in on 28 October 2026, nothing of Rubric runs between jobs.

Before that, the scheduler ran on my PC inside terminals and agent sessions, and when one closed, the scheduler went with it. The editions of 26 and 28 October were missed that way. On the VM I wanted something that starts at boot, catches up after downtime and logs every run, without a scheduler container of its own. systemd already does that.

One service template for every job

# ~/.config/systemd/user/rubric@.service
[Unit]
Description=Rubric pipeline: %i

[Service]
Type=oneshot
ExecStart=/usr/bin/flock --nonblock --conflict-exit-code 0 %t/rubric-%i.lock %h/app/deploy/vm/rubric %i
TimeoutStartSec=6h

%i is whatever follows the @, so rubric@ingest.service runs the ingest job. Type=oneshot tells systemd the job is over when the command exits. flock holds a lock in the user’s runtime directory (%t) while the job runs. A second start of the same job finds the lock taken and exits 0 straight away, so a slow edition never runs twice. TimeoutStartSec=6h is the ceiling for a run that hangs.

The wrapper it calls is a few lines of shell:

#!/bin/sh
set -eu
cd "$(dirname "$(readlink -f "$0")")/../.."
exec docker compose run --rm -T pipeline npx tsx src/cli.ts "$@"

run --rm starts a new container from the image and deletes it afterwards. -T skips the pseudo-terminal, which a timer doesn’t have.

One timer per job

# ~/.config/systemd/user/rubric-due.timer
[Unit]
Description=Rubric: the daily edition

[Timer]
OnCalendar=*-*-* 00:05:00 UTC
OnCalendar=*-*-* 05:45:00 UTC
OnCalendar=*-*-* *:10,25,40,55:00 UTC
AccuracySec=1s
Persistent=true
Unit=rubric@due.service

[Install]
WantedBy=timers.target

Each OnCalendar line adds a schedule. Persistent=true starts a run that was missed while the machine was off as soon as it’s back. A VM that was down at 05:45 still publishes. The source poll’s timer leaves it out on purpose: a missed poll isn’t worth running late, because the edition’s backfill covers that window anyway. AccuracySec defaults to a minute, and the edition’s timer sets one second.

Install as your user, with lingering

units="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
mkdir -p "$units"
cp deploy/vm/rubric@.service deploy/vm/rubric-*.timer "$units/"
systemctl --user daemon-reload
systemctl --user enable --now rubric-due.timer
loginctl enable-linger "$(id -un)"

The units belong to your user, so installing them needs no root. The last line is the one that’s easy to miss. Without lingering, your user’s systemd starts at login and stops at logout, and the timers stop with it. With lingering on, they start at boot and keep running with nobody logged in.

Compose and logs

The compose service sits behind a profile, so docker compose up never starts it. That way no long-running container sits next to the timers and runs a job a second time:

services:
  pipeline:
    build: .
    profiles: [jobs]

The catch is the build. A bare docker compose build then reports “No services to build” and leaves the old image in place, so name the service: docker compose build pipeline.

To see what ran and what’s next:

systemctl --user list-timers 'rubric-*'
journalctl --user -u 'rubric@*' -f
journalctl --user -u rubric@due --since today

Every job logs under its own unit name. Watch the disk when you rebuild. On 29 October an image build filled the VM’s disk to 100%, and docker builder prune got 1.16 GB back.