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

regscale jobs daemonregscale jobs run-due
ShapeOne long-lived processShort-lived, exits after each pass
Triggered byItself, on --intervalYour scheduler — cron, K8s CronJob, Task Scheduler
Best forContainers, systemd, anywhere a process can stay upHosts that already have cron; teams that want no resident process
Cron expression lives injobs.yamljobs.yaml (the external trigger just polls)
Schedule changes need a restart?No — config reloads every passNo

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:

  1. A configured CLI. Domain and credentials, by init.yaml or by
    REGSCALE_* environment variables, exactly as for interactive use.
  2. jobs.yaml, as a file the process can read or as an HTTPS URL
    (--config-url / REGSCALE_JOBS_URL).
  3. A persistent, writable state directory. Set
    REGSCALE_JOBS_STATE_DIR explicitly and give it real storage. Lose it and
    the scheduler forgets which occurrences already ran — every job looks due on
    the next pass.
  4. 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_DIR in 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,
    every docker run starts with no memory of what ran.
  • jobs.yaml is mounted read-only, because the daemon only reads it.
  • --restart unless-stopped covers a host reboot. SIGTERM from
    docker stop is 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: 1 and strategy: 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.
  • terminationGracePeriodSeconds should exceed your longest job, or
    Kubernetes will SIGKILL a job mid-run during any rollout or eviction. The
    timeout_minutes you set on the job is the number to size this against.
  • The PVC must be ReadWriteOnce at minimum and must actually persist. An
    emptyDir defeats 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 list and
    jobs history are the signals, along with failure email.
  • Prefer REGSCALE_JOBS_URL (or a mounted ConfigMap) over baking jobs.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 schedule is the polling interval, not any job's schedule.
    It has to be at least as frequent as your shortest jobs.yaml schedule, or
    those jobs quietly run only once per poll. Every job's real timing still comes
    from jobs.yaml.
  • concurrencyPolicy: Forbid matches what the CLI's own lock does, so the
    two agree. Keep both: the lock also protects against a manual run-due run 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 means ReadWriteOnce on a single-node pool, or ReadWriteMany.

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:

  • PATH is minimal in cron. Use the absolute path to regscale
    (which regscale to 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 own VAR=value header. Nothing
    from .bashrc or .zshrc is present.
  • The crontab's own timezone is irrelevant to when jobs fire — each job's
    timezone field 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_FILE and REGSCALE_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 IgnoreNew mirrors the CLI's own interlock.
  • The service account needs write access to the state directory and read access
    to init.yaml and jobs.yaml. Put them under C:\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\, and regscale 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.


Did this page help you?