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.
:robelongs 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:Zfor 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?
Does a non-root crontab mean the cron daemon is non-root?
How do I keep run history for non-root cron jobs in Docker?
Why does RunWisp get permission denied on /var/lib/runwisp?
Read next
-
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.
-
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.