Docker deploy
Run your own Dockerfile or a ready-made public image behind a domain, with health-checked zero-downtime releases, rollback, persistent paths with backups, logs and container isolation.
When an app does not fit the built-in runtimes (another language version, system libraries, a vendor's image), ZoPanel can run it as a container. It builds the Dockerfile from your repository, or starts a ready-made public image, publishes the container on 127.0.0.1 only and puts the website's nginx, SSL and firewall in front of it. For one-click apps such as n8n or Nextcloud, use the App Store instead; it does not need the permissions below.
Requirements
All of these must be true, otherwise the deployment is refused:
| Requirement | Who sets it | How |
|---|---|---|
| Docker is installed | Administrator | App Store → Install Docker, or Components → Docker. |
| Custom containers are enabled on the server | Root on the server | zopanel ctl feature enable custom-docker (see below). |
| The account may run its own containers | Administrator or reseller | The account belongs to a full administrator, or its package has Own Docker apps turned on in Packages (off by default). A reseller can grant it only if its own package has it. |
| The package includes Git deploy | Administrator or reseller | Git deploy & apps is not turned off in the package's features. |
Enable custom containers once, as root:
sudo zopanel ctl feature enable custom-docker
This switch exists only on the server's command line, not in the web panel, so a stolen panel session cannot start arbitrary containers.
Deploy a Dockerfile from a repository
- Open Websites → New website, choose Git deploy, enter the Repository URL, Branch and, if the
Dockerfileis in a subfolder, the Root directory. Click Create website. - If the repository contains only a
Dockerfile(nopackage.json,requirements.txt,go.mod…), it is detected as Dockerfile and built as a container. - If another framework is detected, the note "A Dockerfile was also found: switch the runtime to docker to build with it instead." appears. On the Deploy tab, turn Auto-detect off in Build & run, set Runtime to Dockerfile (the Type becomes Container (Dockerfile)) and click Save & deploy.
The build context is the root directory: the Dockerfile must be there, and it and .dockerignore must be regular files, not symbolic links. ZoPanel runs docker build --pull, so base images are refreshed on every build, and passes PORT as a build argument (declare ARG PORT if you need it). Install, build and start commands are not used in this mode: put them in the Dockerfile. A build that runs longer than 45 minutes is stopped and the deployment fails.
Deploy a ready-made image
- Create a website (for example Node / Proxy) or open an existing one, and go to its Deploy tab.
- Under Source, choose Ready-made Docker image.
- Enter the Image, for example
nginx:1.27orghcr.io/owner/app:1.2.0, and click Deploy.
Rules for images:
- Public images only (Docker Hub, GHCR or another public registry). Registries on
localhostor an IP address are refused. - Lowercase name, optional tag and digest (
app@sha256:…), at most 255 characters. - Pin a tag. Every deploy pulls the image again, so a moving tag such as
latestcan change between deploys.
ZoPanel turns the image into a release (a one-line Dockerfile FROM the image) so it gets the same health check, zero-downtime switch and rollback as a built Dockerfile.
Ports
The container must listen on one HTTP port. ZoPanel picks it in this order:
- the
PORTenvironment variable, if you set one; - the first
EXPOSEin theDockerfile(for an image, the first TCP port the image exposes); 8080.
Inside the container, PORT is set to that port. Outside, it is published on 127.0.0.1:<internal port> (shown as Internal port on the Deploy tab) and nginx proxies the domain to it. Nothing else of the container is reachable from the internet.
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci && npm run build
ENV PORT=3000
EXPOSE 3000
USER node
CMD ["node", "server.js"]
The app inside should listen on 0.0.0.0:$PORT, not 127.0.0.1: inside a container, 127.0.0.1 is the container itself.
Environment variables
Add variables in the Environment variables card and click Save & redeploy. Up to 200 variables, single-line values. They are passed to the container at start through a root-only file, never on a command line.
- Values are passed literally. Do not wrap them in quotes (Paste .env removes surrounding quotes for you).
- At build time, a variable reaches the
RUNsteps only if theDockerfiledeclares it withARG(see below). The other variables exist only while the container runs.
Variables at build time
When the Dockerfile declares ARG NAME (or ARG NAME=default) and a variable NAME is set in the panel, ZoPanel passes it to docker build as a build argument. The task log lists the names passed (Build arguments from the environment variables: …), never the values.
FROM node:22-alpine AS build
ARG NEXT_PUBLIC_API_URL
WORKDIR /app
COPY . .
RUN npm ci && npm run build
- Only declared names are passed, so other variables (database passwords, API keys) are never built into the image.
- Build argument values are visible in the image history:
docker historyshows them for everyRUNstep after theARG, and anything a step writes with them stays in that layer. Do not declare secrets asARG; read them at runtime from the environment instead. UseARGfor public settings such as a public API URL or a feature flag. - The value of a declared
ARGwithout a panel variable is its default in theDockerfile. - Names that would change how the server's
dockercommand runs are never passed, for examplePATH,HOME,LD_*,DOCKER_*,BUILDKIT_*,GO*,SSL_*and the proxy variables (HTTP_PROXY…). The task log lists the ones skipped.PORTis always the container's port.
Health check and zero-downtime releases
Each release starts in a second container on the idle slot's port. It goes live when the Health check path (default /) answers with an HTTP status below 500 within 120 seconds. nginx then switches to the new container and the old one is removed a few seconds later. If the build fails, the container exits or never becomes healthy, the last 25 lines of its output are written to the task log and the running container keeps serving.
Rollback
The Releases card keeps the last 5 releases, and each keeps its built image. Rollback starts the chosen image in the idle slot, waits for the health check and switches, without rebuilding. Images of older releases are deleted with their release.
Logs and controls
- Application output shows the last 400 lines of the container's output with timestamps; turn on Live to follow it. Docker rotates container logs (3 files of 10 MB).
- The status card shows the container's state, RAM and Internal port. Restart, stop and start act on the running container.
- Each build log (
docker buildoutput) is in Tasks.
Limits and data
| Limit | Value |
|---|---|
| Memory | 1024 MB per container |
| CPU | 1 CPU |
| Processes | 512 |
| Restart policy | Restarted automatically unless stopped |
On servers where Docker uses the systemd cgroup driver (the default on Ubuntu 22.04/24.04 and Debian 12/13), the container also runs inside the account's resource group, so the package's limits apply too.
Builds
docker build runs in the Docker daemon, not as the account's Linux user. With the systemd cgroup driver, the containers of the build's RUN steps are placed in the account's resource group (--cgroup-parent), so the package's CPU and memory limits apply to builds as they do to the running app: a step that needs more memory than the package allows is stopped (Killed in the task log). This works with BuildKit (Docker's buildx component) and with the older builder Docker uses when buildx is not installed. Not covered by the package limits:
- pulling base images, sending the build context and exporting the image, which the Docker daemon does itself;
- the disk space of images and the build cache (under
/var/lib/docker, not in the account's quota).
Every build is stopped after 45 minutes.
Persistent paths
A container's filesystem is recreated on every deploy and rollback, so anything written inside it is lost, except in its persistent paths. On the Deploy tab, in Build & run, enter them in Persistent paths (in the container), comma-separated, then click Save & deploy:
/app/data, /app/uploads
- Each is an absolute folder inside the container, at most 10. Use only letters, digits,
.,_,-and/; no.., no folder inside another one in the list. - System folders are refused:
/proc,/sys,/dev,/etc,/run,/boot,/bin,/sbin,/lib*,/usr/bin,/usr/sbin,/usr/lib*and everything inside them, and/,/usr,/usr/local,/usr/share,/usr/src,/var,/var/lib,/var/cache,/var/log,/home,/opt,/root,/tmp,/mntand/mediathemselves (a folder inside them, such as/var/lib/postgresql/dataor/usr/src/app/uploads, is fine). - Each path is a folder on the server under
/var/lib/zopanel-apps/git-<user>-<domain>/, reachable only by root and by this website's containers. It is mounted at the same path in both release slots: during a deploy the old and the new container use it at the same time for a few seconds, which file-based databases such as SQLite handle. - A new folder starts empty and hides whatever the image has at that path, so use a folder for data, not the folder of your code. It is owned by the image's
USERwhen that is a numeric ID (USER 1000); when the image runs as root it belongs to root in the container; whenUSERis a name (USER node), the folder is writable by every user of the container. Existing data keeps its owners. - Like App Store app data, it is not counted in the account's disk quota.
- The data stays when you change the paths. It is deleted with the website only when you choose to delete its files, and with the account.
- An account move recreates the paths on the new server, empty: copy the data yourself.
The folders can be backed up like the data of App Store apps: a Backups card appears on the Deploy tab once the app has persistent paths. Anyone who manages the website can back up now; administrators set the schedule (daily or weekly, number kept), restore and delete. A backup pauses the container for the time of the copy; a restore stops it, replaces the data and starts it again. Backups are kept in /var/backups/zopanel-apps/ on the server, apart from account backups.
For data shared with other services, a database on another server or object storage, such as ZoPanel's S3 storage published on a domain (reached over HTTPS on port 443), is still the better place.
Isolation
A customer's container runs code the server does not control, so ZoPanel confines it:
- Network: containers cannot open connections to private, shared, loopback or link-local addresses (including the cloud metadata service at
169.254.169.254), nor to other containers. On the server itself they reach only the public ports: web (80, 443), mail (25, 465, 587) and DNS. The server's MySQL, PostgreSQL, Redis and the panel are not reachable from a container. Image builds run under the same rules. - Privileges: every Linux capability is dropped except
CHOWN,DAC_OVERRIDE,FOWNER,SETUID,SETGID,NET_BIND_SERVICEandKILL;no-new-privilegesis set; Docker's default seccomp and AppArmor profiles apply. - User namespaces: root in the container maps to an unprivileged ID range on the host.
- No host access: no Docker socket, and no host folder other than the website's own persistent paths.
If these rules cannot be applied, ZoPanel does not build or start the container.
Troubleshooting
| Message | Cause and fix |
|---|---|
this account's package does not allow its own Docker apps (Dockerfile or image); ask the administrator |
Turn on Own Docker apps in the account's package, or deploy from a full administrator's account. |
Dockerfile deployments are disabled; enable them on the server with: zopanel ctl feature enable custom-docker |
Run the command as root on the server. |
Docker is not installed (install it from the App Store page) |
Install Docker from App Store or Components. |
no Dockerfile in the build directory |
The Dockerfile must be in the Root directory (the name is case-sensitive). |
docker build failed: … |
A build step failed; the task log shows the output. Steps that download from private addresses are blocked. Killed in a step means it needed more memory than the account's package allows. |
docker build did not finish within 45 minutes |
Make the build faster (smaller context with .dockerignore, fewer steps), or build the image elsewhere and deploy it as a ready-made image. |
persistent path … is not allowed / must be an absolute path inside the container |
Use an absolute folder of your app such as /app/data, not a system folder. |
| Data lost after a deploy | It was written outside the persistent paths. Add the folder to Persistent paths (in the container). |
| App cannot write to its persistent path | The folder was created while the image ran as another user, or the app expects files the image had at that path (a new folder starts empty). Use a numeric USER, or chown the folder in an entrypoint that starts as root (the container keeps CHOWN). |
cannot pull … (public images only): … |
The image or tag does not exist, or it is private. |
image must look like nginx:1.27 or ghcr.io/owner/app:v2 |
Use a lowercase image reference. |
the container did not answer on port … within 120s (set PORT or EXPOSE to the port it listens on) |
The app listens on another port or on 127.0.0.1 inside the container. Set PORT, or listen on 0.0.0.0:$PORT. |
the container exited during startup |
Read the container output in the task log: usually a missing variable or a command that ends immediately. |
Docker runs without user-namespace isolation; restart the ZoPanel agent to enable it |
Run sudo systemctl restart zopanel-agent on the server. |
the network isolation for containers could not be set up (iptables); Docker apps are not started without it |
The firewall rules could not be installed. Check that iptables works and Docker is running. |
| App cannot connect to the database | Containers cannot reach the server's own databases, not even through its public IP. Use a database on another server, reachable on a public address. |