← all posts

Run cron jobs as a non-root user in Docker, without setuid

USER app doesn't make Debian cron an unprivileged scheduler. Two complete setups with Supercronic or RunWisp, plus the UID, config and writable-volume checks that stop permission errors.

7 min read

You add USER app to the Dockerfile, start cron -f, and get this instead of a running scheduler:

cron: can't open or create /var/run/crond.pid: Permission denied

Moving the PID file or making a spool directory writable can get you to the next permission error. The scheduler still expects to read system crontabs or change a job’s identity. What you wanted was simpler: run this script every minute, as the same unprivileged user as the container.

Short answer

Use Supercronic with Docker’s USER instruction if you need a crontab and output in docker logs. It executes commands under its own identity. Use RunWisp, an open-source cron replacement that ships as a single binary, if you also want retained run output, exit codes, durations, a web UI and configurable failure alerts. Set Compose’s user: and give it writable data storage. Neither needs setuid or privileged mode for that setup. A root cron daemon launching jobs as app is also valid, but it doesn’t satisfy a requirement that the scheduler itself run under a nonzero UID.

Three meanings of “non-root cron”

Setup Scheduler identity Job identity What it solves
Root cron, app’s user crontab root inside the container app Jobs run without root privileges
Supercronic or RunWisp with USER / user: app app Scheduler and jobs both run without container root
Rootless Docker Depends on the image’s USER Depends on scheduler config Docker itself runs without host root

Docker’s USER selects the default UID and GID for the container’s entrypoint and command. It doesn’t rewrite a program to stop needing root. Docker’s rootless mode changes the engine and user namespace; a process can still report UID 0 inside that namespace. These are separate choices.

For a conventional root daemon, /etc/cron.d/report on Debian can contain:

* * * * * app /app/report.sh

That username is part of the system crontab format. It selects the job’s user. It doesn’t change who runs cron -f. If your requirement only concerns the job’s identity, you may already have what you need.

Why changing permissions doesn’t turn system cron into Supercronic

Debian cron manages a system spool, a PID file and per-user job execution. Alpine’s BusyBox crond has a different implementation, with its own user-switching path; its source explicitly describes running it as root. -c /some/writable/directory changes the spool location, not the execution model.

In our container check, Debian cron under UID 10001 failed at /var/run/crond.pid. BusyBox crond on Alpine 3.22 printed a startup message, followed by root: Permission denied when reading its existing spool. A live process wasn’t evidence of a working job.

crontab is another program again. A distribution may install that helper with elevated permissions so ordinary users can update a protected spool. An error such as “must be suid” from that helper doesn’t mean your application needs a setuid scheduler. The examples below read an ordinary config file directly and never invoke crontab.

Option 1: Supercronic, with a non-root Dockerfile

Create these three files in one directory. First, a job that prints its identity:

#!/bin/sh
# report.sh
set -eu
printf 'report: uid=%s gid=%s at %s\n' "$(id -u)" "$(id -g)" "$(date -u +%FT%TZ)"

Then crontab, with a final newline:

* * * * * /app/report.sh

There’s no username column. The scheduler is already running as the user you chose.

FROM golang:1.27.3-alpine AS scheduler
RUN CGO_ENABLED=0 go install github.com/aptible/[email protected]

FROM alpine:3.22
RUN addgroup -g 10001 app && adduser -D -u 10001 -G app app
COPY --from=scheduler /go/bin/supercronic /usr/local/bin/supercronic
WORKDIR /app
COPY --chmod=644 crontab /app/crontab
COPY --chmod=755 report.sh /app/report.sh
USER 10001:10001
CMD ["supercronic", "/app/crontab"]

The build stage compiles a pinned Supercronic release. Go stays out of the runtime image. Root installs files during the build; the running container uses UID 10001.

docker build -t nonroot-cron .
docker run -d --name nonroot-cron \
  --cap-drop ALL --security-opt no-new-privileges \
  nonroot-cron
docker logs -f nonroot-cron

Wait for the next minute boundary. The job prints uid=10001 gid=10001, along with the timestamp. Supercronic logs its output and result. See its documented crontab and logging behavior.

For an immediate permissions check:

docker exec nonroot-cron /app/report.sh

That confirms the script can execute as the container’s user. Waiting for a scheduled run checks the scheduler too. Replace the demo script with your command and install its runtime and clients in the image. If the job writes files, give it a separate writable directory; the root-owned, readable /app above is sufficient for this output-only example.

Keep this setup if a crontab and container logs are enough. Supercronic doesn’t need a database volume or dashboard password.

Option 2: RunWisp, with non-root Compose and retained history

RunWisp is our project. Its official image defaults to root to support tasks that switch users. For tasks that all run under one UID, override that default. The Docker guide documents this mode.

This example targets ordinary Linux Docker without user namespace remapping. Run the setup as your normal host user:

mkdir -p runwisp-config runwisp-data
export SCHED_UID="$(id -u)"
export SCHED_GID="$(id -g)"
read -r -s -p 'Dashboard password: ' RUNWISP_PASSWORD
printf '\n'
export RUNWISP_PASSWORD

The password prompt uses Bash. Keep the exported variables in the shell where you run Compose, or put them in your deployment’s environment configuration.

Save this as runwisp-config/runwisp.toml:

[daemon]
timezone = "UTC"

[tasks.report]
run = "id; date -u"
cron = "* * * * *"
timeout = "30s"

The command uses tools already in the image. A real backup or application job needs its own tools and code installed there. It runs inside this container.

# compose.yaml
services:
    runwisp:
        image: runwisp/runwisp:1.9.1
        user: "${SCHED_UID:?Set SCHED_UID}:${SCHED_GID:?Set SCHED_GID}"
        restart: unless-stopped
        cap_drop:
            - ALL
        security_opt:
            - no-new-privileges:true
        environment:
            RUNWISP_PASSWORD: "${RUNWISP_PASSWORD:?Set RUNWISP_PASSWORD}"
        ports:
            - "127.0.0.1:9477:9477"
        volumes:
            - ./runwisp-config:/etc/runwisp:ro
            - ./runwisp-data:/var/lib/runwisp
docker compose up -d
docker compose logs -f runwisp

Visit http://localhost:9477 on the Docker host and log in. On a remote VPS, run ssh -L 9477:127.0.0.1:9477 you@server on your laptop, then visit the same localhost address there. The official image already includes tini; this example keeps its entrypoint.

After the daemon reports ready, run the task immediately:

docker compose exec runwisp runwisp run report

Check the output’s UID against SCHED_UID, then check the next scheduled run in the web UI. The history lives in runwisp-data, so recreating the container with the same mount keeps it. Output retention has size and age limits. External failure messages need a notifier and routing; the example above only sets up execution and history.

Don’t add a task-level user = "somebody-else": switching identities needs a root daemon. The container’s user: already selects the identity for every command in this setup.

Permission denied? Check the mounted paths

Changing ownership in a Dockerfile doesn’t change the ownership of a host directory mounted over it. Nor does Compose’s user: chown an existing volume. Check from inside the running container:

docker compose exec runwisp sh -c '
  id
  ls -ldn /etc/runwisp /var/lib/runwisp
  test -r /etc/runwisp/runwisp.toml && echo "config readable"
  touch /var/lib/runwisp/.write-test && rm /var/lib/runwisp/.write-test
'

If the container exits before you can exec into it, use a one-off shell with the same service’s user and mounts:

docker compose run --rm --no-deps --entrypoint sh runwisp

Check these in order:

  • Directory ownership. Create the bind directories before Compose starts. Otherwise Docker can create missing paths as root. Existing data files need the correct ownership too; fixing only the directory doesn’t fix a root-owned database inside it.
  • Directory traversal and config readability. A readable file inside a directory the UID can’t enter still fails. The config mount above is read-only and the config exists before startup, so the entrypoint doesn’t need to write a starter file.
  • First start with an empty config mount. RunWisp can copy its starter into a writable mounted config directory. A non-root UID can’t do that on a root-owned or read-only mount. Pre-create the config as above, or deliberately grant that directory write access.
  • The data mount. RunWisp needs write access for SQLite, run logs and its control socket. :ro belongs on config, not data.
  • Rootless engines and SELinux. Host and container UIDs can map differently under rootless Docker, Podman or userns-remap. Use the engine’s ownership mapping rather than applying the ordinary-Docker UID recipe blindly. On an SELinux host, bind mounts may also need an appropriate label, such as :Z for a private Podman mount.

Avoid chmod 777: grant the chosen UID access to the specific directories it needs. A named volume also needs ownership prepared for that UID; declaring it doesn’t establish who can write its contents.

What we checked, and which setup to keep

We built the Supercronic Dockerfile above and checked RunWisp 1.9.1 with the config shown here using Podman 5.7. Both ran jobs under nonzero UIDs with all capabilities dropped and no-new-privileges enabled. The RunWisp check used Podman’s --userns keep-id for the host-owned data bind mount. These checks cover the images and job identity, not every Docker host policy or an OpenShift deployment.

For one image, one UID and logs on stdout, Supercronic is enough. For browsable results after container-log rotation, RunWisp adds persistent history and alerts at the cost of a data volume, TOML configuration and another daemon to operate. Running several scheduler replicas still runs several copies of the job; neither example provides a distributed lock.

The separate container environment and logging guide covers missing variables and output. For scheduling across a Compose stack, see Docker Compose cron jobs with a web UI. To try RunWisp, follow the Docker quick start; the daemon’s source is on GitHub.

Common questions

Can I run cron as a non-root user in Docker?
Yes, with a scheduler designed to execute jobs under its own UID, such as Supercronic or RunWisp. Docker's USER instruction or Compose user setting chooses the scheduler's identity, and its jobs inherit that identity. Debian cron and Alpine's BusyBox crond are built around system-wide scheduling and switching users; changing their PID or spool directory alone doesn't remove those assumptions.
Does a non-root crontab mean the cron daemon is non-root?
No. On a normal Linux host, a user installs a crontab through the crontab helper and a root cron daemon launches the jobs as that user. Putting an app username in /etc/cron.d changes the job's identity, not the daemon's. Rootless Docker is a third concept: the Docker daemon itself runs without host root privileges.
How do I keep run history for non-root cron jobs in Docker?
RunWisp, an open-source cron replacement that ships as a single binary, runs under a non-root container UID and keeps run output, exit codes and durations on a writable data volume. Its web UI shows that history, and configured notifiers send failure alerts. The official image defaults to root, so set user explicitly and prepare readable config and writable storage. Tasks cannot switch to other users without root, schedules live in TOML, retention is bounded, and multiple containers don't coordinate execution.
Why does RunWisp get permission denied on /var/lib/runwisp?
The UID running the container must be able to write the mounted directory, including existing database and log files. A bind mount hides the image's directory and its ownership. On ordinary Linux Docker without user namespace remapping, match the container UID and GID to the host directory owner. Rootless engines and remapped namespaces need ownership checked through their own UID mappings. chmod 777 is unnecessary.
  1. Windows Task Scheduler alternatives for Linux: cron, systemd timers, KCron or a web UI

    Linux has no single Task Scheduler. cron schedules, systemd timers cover most of its Settings tab, KCron gives cron a desktop editor, and a dashboard like RunWisp adds run history. Every Task Scheduler option mapped to its Linux equivalent, with the gaps.

  2. systemd timer email on failure: an OnFailure= unit that mails the failed run's log

    How to get an email when a systemd timer's service fails: a template OnFailure= unit, msmtp as sendmail, and only that run's journal lines via $MONITOR_INVOCATION_ID. Tested on systemd 259 with timeouts, SIGKILL, Restart=, skipped conditions and a shared handler.

Get started
GPL 3.0 · no accounts · free to self-host

Start in 30 seconds. Ship on your terms.

A single Go binary. If it doesn't earn the disk space, rm it. No account, no signup, no catch.

Install in 30 seconds
curl -fsSL https://get.runwisp.com | sh