Deployment
Something has to be running for a schedule to mean anything. This page covers
the five ways people actually run it.
- Pick a mode first
- What every deployment needs
- Docker
- Docker Compose
- Kubernetes — long-lived daemon
- Kubernetes — CronJob
- systemd
- Host cron
- Windows Task Scheduler
- Operating it
Pick a mode first
regscale jobs daemon | regscale jobs run-due | |
|---|---|---|
| Shape | One long-lived process | Short-lived, exits after each pass |
| Triggered by | Itself, on --interval | Your scheduler — cron, K8s CronJob, Task Scheduler |
| Best for | Containers, systemd, anywhere a process can stay up | Hosts that already have cron; teams that want no resident process |
| Cron expression lives in | jobs.yaml | jobs.yaml (the external trigger just polls) |
| Schedule changes need a restart? | No — config reloads every pass | No |
The important thing is to pick one per state directory. Both modes take the
same cross-process lock, so mixing them is safe from a correctness point of
view — but two schedulers over one config is twice the thing to reason about
when a job does not run.
Keep the cron expressions in jobs.yaml either way. Do not translate a job's
schedule into a K8s CronJob schedule and set the job's own schedule to
something arbitrary: the CLI decides what is due from jobs.yaml, so a mismatch
means jobs that silently never fire.
What every deployment needs
Four things, in every one of the recipes below:
- A configured CLI. Domain and credentials, by
init.yamlor by
REGSCALE_*environment variables, exactly as for interactive use. jobs.yaml, as a file the process can read or as an HTTPS URL
(--config-url/REGSCALE_JOBS_URL).- A persistent, writable state directory. Set
REGSCALE_JOBS_STATE_DIRexplicitly and give it real storage. Lose it and
the scheduler forgets which occurrences already ran — every job looks due on
the next pass. - Somewhere for logs to go. The scheduler logs to stdout/stderr; the jobs'
own output goes to per-run files under the state directory.
Always set
REGSCALE_JOBS_STATE_DIRin a container. The default is
~/.regscale, and in a container image "home" is whatever the runtime decides
— with an arbitrary UID injected by the platform (OpenShift does this),
resolving a home directory can fail outright. An explicit path removes the
question.
Docker
The published image's entrypoint is regscale, so the command is just the
subcommand and its flags.
docker run -d --name regscale-scheduler \
-e REGSCALE_DOMAIN="https://yourinstance.regscale.io" \
-e REGSCALE_TOKEN="$REGSCALE_TOKEN" \
-e REGSCALE_JOBS_STATE_DIR=/data/jobs \
-v regscale-jobs:/data/jobs \
-v "$PWD/jobs.yaml:/config/jobs.yaml:ro" \
--restart unless-stopped \
regscale/regscale-cli:latest \
jobs daemon --jobs-file /config/jobs.yaml --interval 60
-v regscale-jobs:/data/jobs— the named volume is the point. Without it,
everydocker runstarts with no memory of what ran.jobs.yamlis mounted read-only, because the daemon only reads it.--restart unless-stoppedcovers a host reboot.SIGTERMfrom
docker stopis handled cleanly: the daemon finishes the job in flight and
exits, so give it a stop timeout longer than your slowest job
(docker stop -t 600) if you would rather not have long runs killed.
The one-shot form, for a host that has its own scheduler:
docker run --rm \
-e REGSCALE_JOBS_STATE_DIR=/data/jobs \
-v regscale-jobs:/data/jobs \
-v "$PWD/jobs.yaml:/config/jobs.yaml:ro" \
regscale/regscale-cli:latest \
jobs run-due --jobs-file /config/jobs.yaml
To validate a config before shipping it, same image, no volumes needed:
docker run --rm -v "$PWD/jobs.yaml:/config/jobs.yaml:ro" \
regscale/regscale-cli:latest jobs validate --jobs-file /config/jobs.yaml
Prefer a pinned tag (regscale/regscale-cli:6.34.0) over :latest in anything
you care about, so a scheduler does not change version underneath you. There is
also a FIPS variant, :latest-fips, for FedRAMP High and IL5 environments.
Docker Compose
services:
regscale-scheduler:
image: regscale/regscale-cli:latest
command: ["jobs", "daemon", "--jobs-file", "/config/jobs.yaml", "--interval", "60"]
restart: unless-stopped
stop_grace_period: 10m
environment:
REGSCALE_DOMAIN: https://yourinstance.regscale.io
REGSCALE_TOKEN: ${REGSCALE_TOKEN:?set REGSCALE_TOKEN}
REGSCALE_JOBS_STATE_DIR: /data/jobs
SMTP_SERVER: smtp.yourorg.gov
SMTP_FROM: [email protected]
volumes:
- regscale-jobs:/data/jobs
- ./jobs.yaml:/config/jobs.yaml:ro
volumes:
regscale-jobs:
stop_grace_period is worth setting deliberately: the default is 10 seconds,
which will kill a running integration on docker compose down.
Kubernetes — long-lived daemon
apiVersion: apps/v1
kind: Deployment
metadata:
name: regscale-scheduler
spec:
replicas: 1 # see the note below
strategy:
type: Recreate # never two schedulers on one volume
selector:
matchLabels: { app: regscale-scheduler }
template:
metadata:
labels: { app: regscale-scheduler }
spec:
terminationGracePeriodSeconds: 600
containers:
- name: cli
image: regscale/regscale-cli:latest
args: ["jobs", "daemon", "--interval", "60"]
env:
- name: REGSCALE_DOMAIN
value: https://yourinstance.regscale.io
- name: REGSCALE_TOKEN
valueFrom:
secretKeyRef: { name: regscale-credentials, key: token }
- name: REGSCALE_JOBS_STATE_DIR
value: /data/jobs
- name: REGSCALE_JOBS_URL # or mount jobs.yaml, below
valueFrom:
secretKeyRef: { name: regscale-jobs-config, key: url }
volumeMounts:
- { name: state, mountPath: /data/jobs }
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { memory: 2Gi }
volumes:
- name: state
persistentVolumeClaim: { claimName: regscale-jobs-state }
Notes that matter more than the YAML:
replicas: 1andstrategy: Recreate. Two daemons sharing one state
volume are serialized by the file lock rather than double-firing, but a
rolling update briefly runs two pods, and the second one's passes are simply
skipped with a warning — noise with no benefit. One scheduler.terminationGracePeriodSecondsshould exceed your longest job, or
Kubernetes willSIGKILLa job mid-run during any rollout or eviction. The
timeout_minutesyou set on the job is the number to size this against.- The PVC must be
ReadWriteOnceat minimum and must actually persist. An
emptyDirdefeats the whole design. - No liveness probe is offered, because there is no HTTP endpoint to probe.
Alert on the absence of expected runs instead —jobs listand
jobs historyare the signals, along with failure email. - Prefer
REGSCALE_JOBS_URL(or a mountedConfigMap) over bakingjobs.yaml
into an image, so a schedule change is a config change. With a ConfigMap,
remember kubelet propagates edits to the mounted file within about a minute
and the daemon re-reads on its next pass — no restart, no rollout.
Kubernetes — CronJob
The alternative: no resident process, Kubernetes does the waking up.
apiVersion: batch/v1
kind: CronJob
metadata:
name: regscale-run-due
spec:
schedule: "*/15 * * * *" # how often to CHECK, not a job's schedule
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 0 # do not retry; the next pass will pick it up
template:
spec:
restartPolicy: Never
containers:
- name: cli
image: regscale/regscale-cli:latest
args: ["jobs", "run-due"]
env:
- name: REGSCALE_JOBS_STATE_DIR
value: /data/jobs
- name: REGSCALE_JOBS_URL
valueFrom:
secretKeyRef: { name: regscale-jobs-config, key: url }
volumeMounts:
- { name: state, mountPath: /data/jobs }
volumes:
- name: state
persistentVolumeClaim: { claimName: regscale-jobs-state }
- The CronJob's
scheduleis the polling interval, not any job's schedule.
It has to be at least as frequent as your shortestjobs.yamlschedule, or
those jobs quietly run only once per poll. Every job's real timing still comes
fromjobs.yaml. concurrencyPolicy: Forbidmatches what the CLI's own lock does, so the
two agree. Keep both: the lock also protects against a manualrun-duerun by
a human.backoffLimit: 0. A failed pass should not be retried by Kubernetes —
the next scheduled pass re-evaluates what is due, which is the behavior you
want.- The pod must mount the same PVC every time, or state resets on each run.
That meansReadWriteOnceon a single-node pool, orReadWriteMany.
systemd
For a Linux host with a pip-installed CLI. Run it as a dedicated service
account, not root.
/etc/systemd/system/regscale-jobs.service:
[Unit]
Description=RegScale CLI scheduled jobs
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=regscale
Group=regscale
Environment=REGSCALE_CONFIG_FILE=/etc/regscale/init.yaml
Environment=REGSCALE_JOBS_FILE=/etc/regscale/jobs.yaml
Environment=REGSCALE_JOBS_STATE_DIR=/var/lib/regscale/jobs
ExecStart=/usr/local/bin/regscale jobs daemon --interval 60
Restart=on-failure
RestartSec=30
TimeoutStopSec=600
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
sudo install -d -o regscale -g regscale -m 700 /var/lib/regscale/jobs
sudo systemctl daemon-reload
sudo systemctl enable --now regscale-jobs
sudo systemctl status regscale-jobs
journalctl -u regscale-jobs -f
TimeoutStopSec above your longest job keeps systemd from killing a run during
a restart. If you harden the unit with ProtectSystem / ReadWritePaths,
remember the state directory and the CLI's own config and cache paths must stay
writable.
The run-due equivalent is a .service of Type=oneshot plus a .timer with
OnCalendar=*:0/15 — use that if you prefer no resident process. Persistent=true
on the timer is harmless: the CLI's own occurrence tracking is what decides what
runs.
Host cron
The lowest-ceremony option on a host that already has cron.
# Check every 15 minutes for due RegScale jobs
*/15 * * * * REGSCALE_CONFIG_FILE=/etc/regscale/init.yaml REGSCALE_JOBS_FILE=/etc/regscale/jobs.yaml REGSCALE_JOBS_STATE_DIR=/var/lib/regscale/jobs /usr/local/bin/regscale jobs run-due >> /var/log/regscale-jobs.log 2>&1
Three things bite people here, all of them cron rather than the CLI:
PATHis minimal in cron. Use the absolute path toregscale
(which regscaleto find it), and to any file argument.- The environment is not your login shell's. Set every variable the CLI
needs on the crontab line or in the crontab's ownVAR=valueheader. Nothing
from.bashrcor.zshrcis present. - The crontab's own timezone is irrelevant to when jobs fire — each job's
timezonefield decides that. The crontab entry only controls how often the
CLI checks.
Overlapping invocations are handled: a run-due that starts while another is
still going logs a warning and exits without running anything, so a long job
cannot pile up behind itself.
Windows Task Scheduler
$action = New-ScheduledTaskAction `
-Execute "C:\Program Files\Python312\Scripts\regscale.exe" `
-Argument "jobs run-due" `
-WorkingDirectory "C:\ProgramData\RegScale"
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) `
-RepetitionInterval (New-TimeSpan -Minutes 15)
$principal = New-ScheduledTaskPrincipal -UserId "DOMAIN\svc-regscale" `
-LogonType Password -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-MultipleInstances IgnoreNew `
-ExecutionTimeLimit (New-TimeSpan -Hours 8) `
-StartWhenAvailable
Register-ScheduledTask -TaskName "RegScale Scheduled Jobs" `
-Action $action -Trigger $trigger -Principal $principal -Settings $settings
- Set
REGSCALE_JOBS_STATE_DIR,REGSCALE_JOBS_FILEandREGSCALE_CONFIG_FILE
as machine-level environment variables (System Properties → Environment Variables), not user-level — a task running as a service account does not see
your user environment. Restart the task scheduler service after changing them. -MultipleInstances IgnoreNewmirrors the CLI's own interlock.- The service account needs write access to the state directory and read access
toinit.yamlandjobs.yaml. Put them underC:\ProgramData\RegScale
rather than a user profile. - Task Scheduler's own history is not the job log. The real output is in
<state dir>\job_logs\, andregscale jobs history <name>is the summary.
The daemon mode works on Windows too, but running a foreground process as a
service needs a wrapper (NSSM or similar), so run-due on a repeating trigger
is the simpler path.
Operating it
Rolling out a config change. Validate it, then let the daemon pick it up:
regscale jobs validate --jobs-file ./jobs.yaml # in CI, on the file in git
regscale jobs list # on the host, after it lands
No restart is needed for a schedule change in any mode — the config is re-read
every pass. A restart is needed after changing SMTP settings, which are read
once per process.
Watching it. The scheduler's own activity is on stdout (container logs,
journalctl). Per-run output is in <state dir>/job_logs/, and
regscale jobs history <name> is the fastest read on whether a job is healthy.
Configure notify.on_failure so a failure reaches a person without anyone
checking.
Upgrading. Stop the scheduler, upgrade the CLI or pull the new image, start
it again. State and history are forward-compatible; nothing needs migrating. A
job that was due during the gap runs once on startup rather than being skipped.
Moving to another host. Copy the state directory (jobs_state.json and
job_runs/) along with jobs.yaml, or accept that every job looks due once on
the new host's first pass.
Anything that did not behave the way this page says it should:
FAQ and troubleshooting.
Updated about 8 hours ago
