Minimal Alpine container for the Ente CLI with configurable loop or cron scheduling for automated exports.
Built from the latest upstream cli-v* release tag via GitHub Actions and published to Docker Hub for linux/amd64 and linux/arm64.
- Static
ente-clibinary built from a pinnedcli-v*release (sparse checkout ofcli/only) - Digest-pinned base images and commit-SHA-pinned GitHub Actions
- Loop-based scheduled exports by default (runs as non-root
enteuser) - Optional Alpine BusyBox cron scheduling (root; configurable via env or a mounted crontab)
- Timezone support via
TZ+tzdata - Optional Healthchecks.io-compatible success/failure pings
- Minimal image (Alpine + binary + BusyBox crond)
- No CGO dependencies
# Create directories
mkdir -p cli-data data
# Start (uses compose.yaml in this repo)
docker compose up -d
# One-time login (match the loop-mode user)
docker exec -it -u enteuser ente-cli /usr/local/bin/ente-cli account addOptional environment overrides for Compose:
| Variable | Default | Purpose |
|---|---|---|
IMAGE_TAG |
latest |
Image tag |
SCHEDULER |
loop |
loop or cron |
LOOP_INTERVAL |
21600 |
Seconds between loop exports |
CRON_SCHEDULE |
0 */6 * * * |
Cron expression when SCHEDULER=cron |
CROND_LOG_LEVEL |
8 |
BusyBox crond verbosity (8 quiet; 2/0 for debug) |
TZ |
UTC |
Container timezone (affects cron local time + log timestamps) |
HEALTHCHECK_URL |
(empty) | Push URL for Healthchecks.io / Uptime Kuma / similar |
HEALTHCHECK_PROVIDER |
(auto) | healthchecks, uptime-kuma / kuma, or leave empty to auto-detect from the URL |
ENTE_DATA_PATH |
. |
Host directory containing cli-data/ and data/ |
RUN_USER |
enteuser |
User for loop mode after privilege drop |
The following examples use the same data volumes and differ only in the
scheduler configuration. Use one of them as your compose.yaml, or set
SCHEDULER / related variables with the included Compose file.
This is the default. It runs an export immediately when the container starts,
then waits six hours between runs. The process drops from root to enteuser
(uid/gid 1000) after fixing ownership of /cli-data and /data.
services:
ente-cli:
image: wittchy/ente-cli:${IMAGE_TAG:-latest}
container_name: ente-cli
restart: unless-stopped
environment:
SCHEDULER: loop
LOOP_INTERVAL: 21600
TZ: America/Chicago
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
- ${ENTE_DATA_PATH}/cli-data:/cli-data:rw
- ${ENTE_DATA_PATH}/data:/data:rwThis uses Alpine BusyBox crond and runs exports at the scheduled clock times.
The container generates its crontab from CRON_SCHEDULE. Cron mode stays root.
Cron runs one export immediately at container startup, then waits for the next
matching clock time. Between runs, BusyBox crond stays quiet by default (no
every-minute scan noise). A schedule such as 0 */6 * * * therefore runs once
on startup and subsequently at six-hour boundaries (00:00 / 06:00 / 12:00 /
18:00 in the container TZ) — silence at other times is expected.
services:
ente-cli:
image: wittchy/ente-cli:${IMAGE_TAG:-latest}
container_name: ente-cli
restart: unless-stopped
environment:
SCHEDULER: cron
CRON_SCHEDULE: "0 */6 * * *"
TZ: America/Chicago
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
- ${ENTE_DATA_PATH}/cli-data:/cli-data:rw
- ${ENTE_DATA_PATH}/data:/data:rwFor the loop scheduler, set the interval in seconds:
environment:
SCHEDULER: loop
LOOP_INTERVAL: 21600For the cron scheduler, set a standard five-field cron expression:
environment:
SCHEDULER: cron
CRON_SCHEDULE: "0 */6 * * *"
TZ: America/ChicagoIn cron mode, the container creates /etc/crontabs/root from
CRON_SCHEDULE when that file does not already exist. To manage the complete
crontab yourself, mount it at that path:
environment:
SCHEDULER: cron
volumes:
- ${ENTE_CRONTAB_PATH}:/etc/crontabs/root:roThe mounted file must contain the full BusyBox crontab entry, including the command. For example:
| Schedule | Crontab line |
|---|---|
| Daily at 2 AM | 0 2 * * * /entrypoint.sh --run-export >> /proc/1/fd/1 2>&1 |
| Every 6 hours | 0 */6 * * * /entrypoint.sh --run-export >> /proc/1/fd/1 2>&1 |
| Every 30 minutes | */30 * * * * /entrypoint.sh --run-export >> /proc/1/fd/1 2>&1 |
Full format:
0 */6 * * * /entrypoint.sh --run-export >> /proc/1/fd/1 2>&1
crond runs in the foreground in cron mode, so it remains the container's
main process. Generated crontabs bake in SHELL, PATH, TZ, and
HEALTHCHECK_URL because BusyBox cron does not reliably inherit the
container environment for jobs. Crontabs are regenerated from env on each
start unless the crontab file is mounted read-only.
Both scheduler modes write export start/output/completion messages to
standard output (visible in Docker, Portainer, and Arcane live logs), prefixed
with ente-cli:. Cron is quiet between scheduled runs; set CROND_LOG_LEVEL=2
(or 0 for most verbose) to debug BusyBox crond itself. The default is 8
(BusyBox's quiet default).
At startup, the container also logs the selected scheduler, for example:
Selected scheduler: loop (every 21600 seconds)
or:
Selected scheduler: cron
Cron will stay quiet until the next schedule match (CRON_SCHEDULE=0 */6 * * *)...
Starting Alpine BusyBox crond in foreground (logging to stdout)...
Set TZ to any zoneinfo name shipped with Alpine tzdata (for example
America/Chicago, Europe/Berlin, UTC). This affects:
- BusyBox cron schedule evaluation (local clock)
- Timestamps in container logs
environment:
TZ: America/ChicagoSet HEALTHCHECK_URL to a push endpoint. The provider is auto-detected from
the URL (/api/push/ → Uptime Kuma; otherwise Healthchecks.io-style), or set
explicitly with HEALTHCHECK_PROVIDER (healthchecks, uptime-kuma, or
kuma).
| Event | Request |
|---|---|
| Job start | GET $HEALTHCHECK_URL/start |
| Job success | GET $HEALTHCHECK_URL |
| Job failure | GET $HEALTHCHECK_URL/fail |
environment:
HEALTHCHECK_URL: https://hc-ping.com/your-uuid-hereCreate a Push monitor in Uptime Kuma and paste its push URL. Example for a
host like https://up.example.com:
environment:
HEALTHCHECK_URL: https://up.example.com/api/push/TOKEN
# Optional — auto-detected when the URL contains /api/push/
# HEALTHCHECK_PROVIDER: uptime-kuma| Event | Request |
|---|---|
| Job start | (skipped — Kuma has no start heartbeat; sending one would reset the timer early during long exports) |
| Job success | GET $HEALTHCHECK_URL?status=up&msg=ok |
| Job failure | GET $HEALTHCHECK_URL?status=down&msg=fail |
If the push URL already has a query string, parameters are appended with &.
Heartbeat interval: set the Kuma monitor’s heartbeat slightly longer than
your export interval so a slow job still finishes before the monitor goes down.
Examples: loop every 6h (LOOP_INTERVAL=21600) → heartbeat 7–8h; cron every
6h → heartbeat ~7–8h.
Ping failures are logged as warnings and never fail the export job itself.
# List accounts (use -u enteuser in loop mode so files stay owned correctly)
docker exec -it -u enteuser ente-cli /usr/local/bin/ente-cli account list
# Run an export manually
docker exec -it -u enteuser ente-cli /usr/local/bin/ente-cli export| Path | Purpose |
|---|---|
/cli-data |
Account credentials & config (persist across restarts) |
/data |
Export destination (decrypted files) |
/etc/crontabs/root |
Optional cron schedule (mounted read-only) |
A mock-based smoke test covers both schedulers without an Ente account:
./scripts/smoke-test.shIt asserts that loop mode drops to enteuser and fires repeatedly, and that
cron mode runs an initial export plus a BusyBox crond-scheduled export with
baked-in TZ / HEALTHCHECK_URL.
The image is built automatically by GitHub Actions on every push to main
(and daily when a new upstream cli-v* tag appears). Images are tagged as:
wittchy/ente-cli:latestwittchy/ente-cli:cli-v0.3.0(upstream release tag)wittchy/ente-cli:v0.3.0(version withcli-prefix stripped)wittchy/ente-cli:<github-sha>
A daily cleanup workflow keeps latest, all cli-v* / v* release tags, and
the 6 most recent other tags (git SHAs). For each tag it removes, it also
deletes the multi-arch index and per-arch manifests via the Registry API so
buildx pushes do not leave dangling digests. Untagged leftovers from older
tag-only cleanups may still need a one-time pass in the Docker Hub UI (Hub
does not expose untagged-image deletion via JWT alone).
Schedule skip detection uses the Actions cache (last built upstream tag).
When the pin in UPSTREAM_TAG is stale after a successful build, CI opens a
PR on chore/upstream-tag instead of committing directly to main.
To build locally against the recorded upstream release:
TAG="$(cat UPSTREAM_TAG)"
docker build \
--build-arg UPSTREAM_REF="$TAG" \
--build-arg VERSION="${TAG#cli-}" \
-t "wittchy/ente-cli:${TAG}" .- Exports are decrypted on disk — protect the
/dataand/cli-datavolumes - Back up
/cli-datato avoid re-authenticating - Loop mode drops to
enteuser(uid 1000). Cron mode stays root for BusyBoxcrond - If you previously ran as root,
/datafiles may still be root-owned; fix once withchown -R 1000:1000 data - Do not expose the Docker host or Portainer to the internet
- Prefer binding volumes to host paths with restricted permissions
The ente-cli binary is licensed under AGPL-3.0.