Docker images and a compose stack for running ZoneMinder, with an optional event server (zmeventnotificationNg) and object detection.
| Path | What it is |
|---|---|
release/zoneminder/ |
ZoneMinder installed from the zmrepo packages on Debian stable. Two targets: zm and zm-es. |
release/pyzm-serve/ |
ML inference server the event server hook sends frames to. |
docker-compose.yaml |
MariaDB + ZoneMinder, with pyzm-serve behind the ml profile. Pulls the published images. |
compose.build.yaml |
Override that builds the images from this repo instead. |
compose.gpu.yaml |
Override that runs pyzm-serve on an NVIDIA GPU. |
tests/smoke.sh |
End-to-end test of the whole stack. CI runs it before publishing. |
tests/pyzm-cuda.sh |
Test for the CUDA pyzm-serve image. |
All images are on Docker Hub for amd64 and arm64. zoneminderhq/zoneminder carries the same tags
as zoneminder/zoneminder, so anyone still pulling the old name gets current images.
| Image | Tags |
|---|---|
zoneminder/zoneminder |
latest, 1.38, 1.38.6: newest release. dev, 1.39, ...: nightly development build. |
Each tag also has an -es version with the event server: latest-es, 1.38-es, dev-es. |
|
zoneminder/pyzm-serve |
latest, 2.5.4: CPU, amd64 and arm64. cuda, 2.5.4-cuda: NVIDIA GPU, amd64 only. |
CI rebuilds every day. latest moves to a new minor release (1.40, 1.42) as soon as zmrepo
publishes it, with no change to this repo. Pin 1.38 if you want to choose when to upgrade.
The dev tags follow ZoneMinder master, which uses the odd minor versions. Don't run them on a
system you rely on.
cp .env.example .env # set ZM_DB_PASS, MARIADB_ROOT_PASSWORD and TZ
docker compose up -dOpen http://localhost:8080/zm and finish ZoneMinder's own first-run setup:
- 1.38 starts with authentication off. Turn it on in Options > System > OPT_USE_AUTH and change
the
adminpassword (defaultadmin) under Options > Users. - 1.39 and later (the
devtags) start with authentication on. The first login page asks you to set theadminpassword or turn authentication off. - Answer the usage statistics prompt. Until you do, the web UI and image URLs redirect to it, and the event server hook can't fetch frames.
To use go2rtc for live view, set Options > System > GO2RTC_PATH to an address the browser can
reach, for example http://zm.lan:1984/api.
docker compose ps shows the container's health. It reports unhealthy when Apache stops serving
/zm/, when the zmdc.pl daemon is gone, or when the database doesn't answer.
docker inspect --format '{{json .State.Health}}' zoneminder-zoneminder-1 shows which. Plain Docker
doesn't restart unhealthy containers, so a database outage leaves ZoneMinder running and capturing
until the database comes back. If zmdc.pl itself dies, the container exits and the restart policy
starts it again. A single camera going offline doesn't count as unhealthy. ZoneMinder's own
zmwatch restarts stuck capture daemons, and the API shows per-monitor status.
The event server reads ZoneMinder's shared memory, so it has to run in the same container as
ZoneMinder. That is what the zm-es image is. The ML models don't have that constraint, so
they run in their own container and the hook sends JPEG frames to it over HTTP.
# push notifications, WebSocket (port 9000) and MQTT, no detection
ZM_TAG=latest-es docker compose up -d
# plus object detection
ZM_TAG=latest-es ZMES_USE_HOOKS=yes docker compose --profile ml up -dSet these in .env rather than on the command line once you have settled on a setup.
On first start the container copies the event server config into the config volume, at
/config/es/. Edit the files there and restart the container. The ZMES_* variables only
apply to that first copy. Delete a file to have it regenerated.
secrets.ymlholds the ZM user and password the hook logs in with. Once authentication is on, put your ZoneMinder login there asZM_USERandZM_PASSWORD, then restart the container.objectconfig.ymlpoints athttp://pyzm-serve:5000and only runs object detection. Face and licence plate recognition need extra models or API keys, so they are left off.- The event server checks WebSocket logins (zmNinja, zmNinjaNG) against ZoneMinder's users even
while ZoneMinder's own authentication is off. A fresh 1.38 install's login is then
admin/admin, so change that password, or turn offauthinzmeventnotification.yml. - WebSocket TLS uses
/config/ssl/es.crtandes.key. The container generates a self-signed pair if none exists. zmNinjaNG needs a real certificate for picture notifications, so put yours there.
pyzm-serve downloads yolo11s on first start and serves it as YOLO ONNX, the name
objectconfig.yml asks for. To use a different model set PYZM_MODELS, for example
PYZM_MODELS=YOLO ONNX=yolo26s.
The cuda tag runs detection on an NVIDIA GPU. pyzm runs models through OpenCV, and the
OpenCV packages on PyPI have no CUDA support, so this image carries its own OpenCV build with
CUDA and cuDNN. It is amd64 only, supports Turing (GTX 16xx, RTX 20xx) and newer cards, and is
about 3 GB to download.
The host needs the NVIDIA driver and the
NVIDIA Container Toolkit.
Check with docker run --rm --gpus all ubuntu nvidia-smi, then add compose.gpu.yaml:
docker compose -f docker-compose.yaml -f compose.gpu.yaml --profile ml up -dIf the container can't see a GPU, it exits with a message saying so, rather than running on the
CPU. pyzm moves a model to the CPU when CUDA fails at runtime, and retries the GPU later. The
container's health check reports unhealthy while that's happening, and the logs show the CUDA error.
To run the cuda image on the CPU anyway, set PYZM_PROCESSOR=cpu.
CI builds this image and checks that OpenCV has CUDA and that detection works, but GitHub's
runners have no GPU, so nothing tests it on a GPU before release. If it doesn't work on your
card, open an issue with the pyzm-serve logs and the output of nvidia-smi.
ZoneMinder container environment:
| Variable | Default | |
|---|---|---|
ZM_DB_HOST |
db |
host, host:port or localhost:/path/to/socket |
ZM_DB_NAME |
zm |
|
ZM_DB_USER |
zmuser |
|
ZM_DB_PASS / ZM_DB_PASS_FILE |
required | Use the _FILE form with Docker secrets |
ZM_DB_SSL_CA_CERT, ZM_DB_SSL_CLIENT_CERT, ZM_DB_SSL_CLIENT_KEY |
TLS to the database | |
ZM_SERVER_HOST |
Server name for multi-server installs | |
TZ |
UTC |
Applied to the OS and PHP. Give the database the same value, as the compose file does. |
ZM_ES_ENABLE |
1 in -es images |
Writes OPT_USE_EVENTNOTIFICATION to conf.d, which takes priority over the UI setting |
ZM_DB_WAIT_TIMEOUT |
180 |
Seconds to wait for the database |
NO_START_ZM |
0 |
1 starts Apache and the database checks but not ZoneMinder, for debugging |
PUID / PGID |
33 / 33 |
Run ZoneMinder, Apache and go2rtc as this user and group. See below. |
SMTP_HOST |
Mail server. Setting it writes /etc/msmtprc. See below. |
|
SMTP_PORT |
587 |
|
SMTP_TLS |
starttls, or tls on port 465 |
starttls, tls or off |
SMTP_USER / SMTP_PASS / SMTP_PASS_FILE |
Login. Leave SMTP_USER empty for a server without one. |
|
SMTP_FROM |
SMTP_USER |
Sender address |
The database settings go to /etc/zm/conf.d/03-docker.conf on every start. zm.conf stays as
the package installed it. Apart from creating and upgrading the schema, the container doesn't
change ZoneMinder's settings. Everything else is set in the web UI, as on any other install.
At start the container also:
- creates the schema if the database is empty, otherwise runs
zmupdate.plto upgrade it, then runszmupdate.pl --freshen, which also replaces the shippedZM_AUTH_HASH_SECRETwith a random one - adds
www-datato the groups that own any passed-through/dev/dri,/dev/video*or/dev/nvidia*devices
The container runs as www-data, which is 33:33. If your volumes are bind mounts that belong to
a user on the host, set PUID and PGID to that user's IDs, for example PUID=1000 PGID=1000.
At start the container gives www-data those IDs and re-owns the files it owned before: the
image's own files, /config, /var/log/zm, and the top two levels of the events folder.
It doesn't walk the whole events folder, because that can take hours. If you change the IDs on an install that already has events, ZoneMinder can still record, but it can't delete the old events until you re-own them once. The log says so when that's needed:
docker compose exec zoneminder chown -R www-data:www-data /var/cache/zoneminderSet SMTP_HOST, plus SMTP_USER and SMTP_PASS if the server needs a login, and the container
writes an msmtp config for it. Then in ZoneMinder's Options > Email turn on OPT_EMAIL,
NEW_MAIL_MODULES and SSMTP_MAIL, set SSMTP_PATH to /usr/bin/msmtp, and fill in the
addresses. The container doesn't change those settings for you.
To check the settings without ZoneMinder:
docker compose exec -u www-data zoneminder sh -c \
'printf "Subject: test\n\nhello\n" | msmtp -t you@example.com'Volumes:
| Path | |
|---|---|
/var/cache/zoneminder |
Events, images and the cache. This one gets big. |
/var/log/zm |
ZoneMinder logs. The main ones are also copied to docker logs. |
/config |
go2rtc.yaml, event server config and certificates |
/dev/shm |
tmpfs, 1 GiB by default (ZM_SHM_SIZE). ZoneMinder keeps each monitor's frame buffer here. |
Ports: 80 (web), 1984 (go2rtc API), 8555 tcp/udp (WebRTC), 9000 (event server).
Uncomment the devices: block in docker-compose.yaml, then set DecoderHWAccelName and
DecoderHWAccelDevice on the monitor. The image ships the Mesa VA-API drivers, plus Intel's
media driver on amd64. docker compose exec zoneminder vainfo shows what the container can see.
The old zoneminderhq/zoneminder images ran MySQL inside the ZoneMinder container. Dump the
database from the old container and load it into the new one:
docker exec old-zoneminder mysqldump --single-transaction --routines --triggers zm > zm.sql
docker compose up -d db
docker compose exec -T db mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" zm < zm.sql
docker compose up -dzmupdate.pl upgrades the schema to 1.38 on first start, and the logs show each step. Events
live in the same place (/var/cache/zoneminder/events), so point the events volume at the old
directory, or bind mount it.
# same as the published images
docker compose -f docker-compose.yaml -f compose.build.yaml build
ZM_TARGET=zm-es ZM_TAG=local-es docker compose -f docker-compose.yaml -f compose.build.yaml up -d --build
# or straight from the Dockerfile
docker build -t zoneminder:local release/zoneminder
docker build --target zm-es --build-arg ZM_CHANNEL=master -t zoneminder:dev-es release/zoneminderBuild arguments:
| Argument | Default | |
|---|---|---|
ZM_CHANNEL |
release |
release finds the newest release-1.x channel on zmrepo. master is the nightly build. A directory name such as release-1.38 stays on that release. |
ZM_VERSION |
newest | Exact package version, for example 1.38.6+trixie1 |
BASE_IMAGE |
debian:stable-slim |
Tracks the current Debian release. ubuntu:26.04 and ubuntu:24.04 also work, because zmrepo builds for Ubuntu LTS too. The scripts read the codename from the image, so you don't need to change anything else. |
Docker caches the "newest version" lookup with the rest of the layer. Use --no-cache, or pass
ZM_VERSION as CI does, to pick up a newer package. s6-overlay, go2rtc, zmeventnotificationNg
and pyzm are pinned in ARG lines at the top of each Dockerfile.
tests/smoke.sh builds the zm-es and pyzm-serve images and starts the full stack. It then
checks schema creation, the web UI and API, go2rtc, and capture from an RTSP test stream.
The test camera loops a photo of a bus that switches between blurred and sharp, so ZoneMinder's
own motion detection opens the events. tests/es-client.pl then acts like zmNinja: it logs in to
the event server over the TLS WebSocket and waits for the alarms. The start alarm has to carry the
hook's detection, and the event's notes have to show it.
After that it checks PUID/PGID and mail, stops the database for 45 seconds, then restarts the container to check init runs cleanly a second time and that the log has no PHP fatal errors. It takes about five minutes once the images are cached.
tests/smoke.sh # newest release
ZM_CHANNEL=master tests/smoke.sh # nightlyThe images workflow runs it for both channels on every pull request, on every push to master and
once a day. On master it then builds amd64 and arm64 and pushes to Docker Hub.
The pyzm-serve-cuda workflow builds the CUDA image when release/pyzm-serve changes and twice a
week, runs tests/pyzm-cuda.sh, and pushes from master. The first build compiles OpenCV for seven
GPU generations. That took 6 minutes on a 16-core machine, and takes longer on GitHub's 4-core
runners. Later builds reuse it from the Actions cache. To build it yourself:
docker build -f release/pyzm-serve/Dockerfile.cuda -t pyzm-serve:cuda release/pyzm-serve
# much faster: compile for your card only, e.g. 8.6 for RTX 30xx
docker build -f release/pyzm-serve/Dockerfile.cuda --build-arg CUDA_ARCH_BIN=8.6 \
-t pyzm-serve:cuda release/pyzm-serve
tests/pyzm-cuda.sh pyzm-serve:cuda
GPU=1 tests/pyzm-cuda.sh pyzm-serve:cuda # also detect on the GPU and compare timingsPushing needs the DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets, with a token that can write
to both the zoneminder and zoneminderhq organisations.
To try the publishing steps without touching the real images, run either workflow by hand from
the Actions tab and tick "test_push". It runs from any branch and pushes to -test repos instead:
zoneminder/zoneminder-test, zoneminderhq/zoneminder-test and zoneminder/pyzm-serve-test.
Create those repos first and give the CI user write access to them, as for the real ones.
Use the forums for questions. For bugs in these images, open an
issue with your compose file (passwords removed) and the output of docker compose logs zoneminder.