# 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.

Source: https://zopanel.net/docs/docker-deploy  
Updated: 2026-10-09

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](/docs/apps) 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:

```bash
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

1. Open **Websites → New website**, choose **Git deploy**, enter the **Repository URL**, **Branch** and, if the `Dockerfile` is in a subfolder, the **Root directory**. Click **Create website**.
2. If the repository contains only a `Dockerfile` (no `package.json`, `requirements.txt`, `go.mod`…), it is detected as **Dockerfile** and built as a container.
3. 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

1. Create a website (for example **Node / Proxy**) or open an existing one, and go to its **Deploy** tab.
2. Under **Source**, choose **Ready-made Docker image**.
3. Enter the **Image**, for example `nginx:1.27` or `ghcr.io/owner/app:1.2.0`, and click **Deploy**.

Rules for images:

- Public images only (Docker Hub, GHCR or another public registry). Registries on `localhost` or 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 `latest` can 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:

1. the `PORT` environment variable, if you set one;
2. the first `EXPOSE` in the `Dockerfile` (for an image, the first TCP port the image exposes);
3. `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.

```dockerfile
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 `RUN` steps only if the `Dockerfile` declares it with `ARG` (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.

```dockerfile
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 history` shows them for every `RUN` step after the `ARG`, and anything a step writes with them stays in that layer. Do not declare secrets as `ARG`; read them at runtime from the environment instead. Use `ARG` for public settings such as a public API URL or a feature flag.
- The value of a declared `ARG` without a panel variable is its default in the `Dockerfile`.
- Names that would change how the server's `docker` command runs are never passed, for example `PATH`, `HOME`, `LD_*`, `DOCKER_*`, `BUILDKIT_*`, `GO*`, `SSL_*` and the proxy variables (`HTTP_PROXY`…). The task log lists the ones skipped. `PORT` is 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 build` output) 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**:

```text
/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`, `/mnt` and `/media` themselves (a folder inside them, such as `/var/lib/postgresql/data` or `/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 `USER` when that is a numeric ID (`USER 1000`); when the image runs as root it belongs to root in the container; when `USER` is 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](/docs/apps) 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_SERVICE` and `KILL`; `no-new-privileges` is 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. |

## Related

- [Git deploy](/docs/git-deploy)
- [App Store and S3 storage](/docs/apps)
- [Packages and limits](/docs/packages-limits)
- [Security](/docs/security)
- [Dockerfile reference](https://docs.docker.com/reference/dockerfile/)
