# How to run Docker apps securely on a VPS: 9 things to get right

> Run Docker apps securely on a VPS - localhost-only ports, user namespaces, memory limits, network isolation and SSL - with sample configs and the ZoPanel way.

Source: https://zopanel.net/blog/docker-apps-on-vps  
Updated: 2026-10-21

## Key takeaways

- Ports Docker publishes bypass UFW, so publish on 127.0.0.1 and reach apps through a reverse proxy with SSL.
- User namespace remapping (userns-remap) maps root inside a container to an unprivileged UID on the host.
- Every container should have memory, CPU and process limits and be unable to gain new privileges.
- Anyone who can control the Docker daemon effectively has root on the server.

To run Docker apps securely on a VPS, get four things right: **publish ports on 127.0.0.1 only** and let visitors in through nginx with SSL, **turn on user namespace remapping** so root in a container is not root on the host, **set memory, CPU and process limits** on every container, and **never hand a container the Docker socket or system directories**. Pinned image versions, volume backups and log rotation then keep a server running a dozen apps from surprising you.

This guide works through each point, explains why Docker's defaults are not enough for an internet-facing VPS, gives a sample `daemon.json` and `docker run` command for Ubuntu 24.04 and Debian 12, and shows how ZoPanel's App Store applies the same protections for you.

## Why aren't Docker's defaults safe on a VPS?

Docker is wonderfully convenient: one `docker run` and you have n8n, Uptime Kuma or Nextcloud. That convenience comes with defaults many people only notice after an incident.

**Published ports bypass your firewall.** When you run `-p 8080:80`, Docker adds its own iptables rules to forward traffic into the container. Docker's own [packet filtering documentation](https://docs.docker.com/engine/network/packet-filtering-firewalls/) explains that packets are routed before UFW's rules apply, and that Docker and UFW are incompatible. You blocked port 8080 in UFW; the whole internet can still reach it. This is the most common self-hosting mistake on a VPS: an app's admin page sitting on a public IP, without SSL, unnoticed.

**Root in a container is close to root on the host.** By default UID 0 inside the container is UID 0 on the host, held back only by namespaces, capabilities and seccomp. If the app has a container-escape bug, the attacker leaves with root.

**No resource limits.** A container without `--memory` can take every byte of RAM. One leaking app is enough for the kernel to kill your database, mail server and every website on the box.

**Containers can reach each other and your internal services.** On the default bridge, containers can connect to each other, and often to MariaDB or Redis listening on the host. One compromised app becomes a stepping stone.

**The Docker daemon runs as root.** Docker's [security documentation](https://docs.docker.com/engine/security/) says only trusted users should control the daemon, and the post-install guide warns that the `docker` group grants root-level privileges. Adding your SSH user to `docker` to save typing `sudo` gives that user root.

## Nine things to run containers safely

### 1. Install Docker from the official repository

On Ubuntu 24.04 or Debian 12, install Docker Engine by following the [official instructions](https://docs.docker.com/engine/install/ubuntu/): add Docker's apt repository and verify its GPG key. Avoid install scripts from unknown sources. Afterwards, do not add ordinary users to the `docker` group unless you accept that they are effectively root.

### 2. Publish ports on 127.0.0.1 only

Instead of `-p 3001:3001`, write:

```bash
-p 127.0.0.1:3001:3001
```

The container listens on loopback only and the internet cannot touch it. Users reach the app through nginx (step 7). As a safety net, set `"ip": "127.0.0.1"` in `/etc/docker/daemon.json`, so a port published without an address still stays private.

### 3. Turn on user namespace remapping

With `userns-remap`, root inside a container maps to a high, unprivileged UID range on the host. Docker's [userns-remap guide](https://docs.docker.com/engine/security/userns-remap/) enables it in `daemon.json`:

```json
{
  "userns-remap": "default"
}
```

One important catch: enabling it hides existing images and containers (they live in a different data directory), so do it on a fresh server. It is also incompatible with `--network=host` and `--pid=host`, and host-mounted folders need their ownership arranged in advance.

### 4. Block container-to-container traffic and internal networks

Add `"icc": false` so containers on the default bridge cannot talk to each other directly. That is only half the job. Containers can still connect to services on the host (database, Redis, the panel) and to the cloud provider's metadata service at `169.254.169.254`, which sometimes holds credentials. Blocking those ranges takes your own iptables rules in the `DOCKER-USER` chain, which is the easiest part to get wrong by hand. At the very least, keep databases on a socket or 127.0.0.1 with strong passwords, rather than hoping a container never learns the address.

### 5. Limit every container's resources

```bash
--memory 512m --cpus 1 --pids-limit 512
```

`--memory` stops one app from eating the server's RAM; `--pids-limit` stops fork bombs. Add up the memory limits of all your containers and keep at least 1 GB more for the system, databases and web server. If the total exceeds your real RAM, you are overselling your own server.

### 6. Prevent privilege escalation

```bash
--security-opt no-new-privileges --cap-drop NET_RAW --cap-drop MKNOD
```

`no-new-privileges` stops processes from gaining privileges through setuid binaries. Dropping capabilities the app does not need (such as `NET_RAW`, used to forge packets) narrows what a compromised container can do. `"no-new-privileges": true` in `daemon.json` applies it to every container.

### 7. Expose apps through nginx with SSL

A minimal server block for an app listening on 127.0.0.1:3001:

```nginx
server {
    listen 443 ssl;
    server_name status.example.com;
    # ssl_certificate ... (Let's Encrypt)

    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}
```

The `Upgrade`/`Connection` lines are needed by apps that use WebSockets, such as Uptime Kuma and n8n. Get the certificate from Let's Encrypt; some apps, Vaultwarden among them, refuse to work over plain HTTP.

### 8. Pin image versions and update on a schedule

Use a specific tag (for example `louislam/uptime-kuma:1`) rather than a drifting `latest`, so you know exactly what is running. Update monthly, and watch the upstream project: an app that no longer receives security fixes should be replaced even if it still runs.

### 9. Rotate logs and back up volumes

Docker's JSON logs grow without limit by default and can fill a disk. Add to `daemon.json`:

```json
{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}
```

The real data lives in volumes. Include them in your backup schedule and test a restore at least once.

## A sample daemon.json and run command

Putting it together, `/etc/docker/daemon.json` on a new VPS might look like this:

```json
{
  "ip": "127.0.0.1",
  "icc": false,
  "userns-remap": "default",
  "no-new-privileges": true,
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}
```

Apply it with `sudo systemctl restart docker` (running containers restart). Then run an app, for example Uptime Kuma:

```bash
docker run -d --name uptime-kuma --restart unless-stopped \
  -p 127.0.0.1:3001:3001 \
  --memory 512m --cpus 1 --pids-limit 512 \
  --cap-drop NET_RAW --cap-drop MKNOD \
  -v uptime-kuma-data:/app/data \
  louislam/uptime-kuma:1
```

Check that the port really is local only:

```bash
sudo ss -ltnp | grep 3001
# expect 127.0.0.1:3001, not 0.0.0.0:3001
```

From another machine, `curl http://SERVER-IP:3001` must fail. If it answers, the port is exposed.

## Common mistakes

| Mistake | Result | Do this instead |
|---|---|---|
| `-p 8080:80` and trusting UFW to block it | App exposed to the internet | `-p 127.0.0.1:8080:80` plus a reverse proxy |
| Mounting `/var/run/docker.sock` "for convenience" | The container controls Docker, so it has root | Don't, except for tools you fully trust |
| `--privileged` to fix a permission error | Most isolation switched off | Find the one capability that is missing |
| No `--memory` | One app takes down the server | A memory limit per container |
| Mounting the host's `/` or `/etc` | The container can change the system | Mount only the app's own data folder |
| Leaving a fresh app open before creating the admin | A stranger creates the admin account first | Create the admin immediately, or lock access until you have |

The last row is rarely mentioned and very real: in n8n, Gitea, Nextcloud and many others, **the first person to open the page creates the administrator account**. Bots that watch new SSL certificates can find your domain within minutes.

## Running Docker apps securely with ZoPanel

ZoPanel applies these protections when you install Docker through **App Store → Install Docker** or **Components → Docker**. As the [App Store documentation](/docs/apps) describes:

- **User namespaces** (`userns-remap`) are turned on automatically, mapping container UIDs to a range no hosting account uses.
- **Containers publish ports on 127.0.0.1 only.** Visitors reach apps through the website's nginx, with a Let's Encrypt certificate issued automatically once the domain points to the server.
- **Containers cannot connect to each other**, whatever `icc` says in `daemon.json`. They are also cut off from private ranges, link-local addresses (the metadata service at `169.254.169.254`) and loopback; on the server itself they reach only the ports the internet reaches too (web, mail, DNS), not the database, Redis or the panel. If these firewall rules cannot be applied, ZoPanel does not start containers.
- Each container runs with `no-new-privileges`, a 512-process limit, `NET_RAW`, `MKNOD` and `AUDIT_WRITE` dropped, and **a memory limit and 1 CPU**. A customer's container runs inside that customer's own resource group.
- Data lives in `/var/lib/zopanel-apps/<instance>/`, readable only by root and the container; environment variables go through a root-only file, not the command line. Logs rotate at 3 files of 10 MB.

**Installing an app** takes four steps: open **App Store**, pick an app (n8n, Uptime Kuma, Nextcloud, Gitea, Vaultwarden, Metabase, Ghost, Open WebUI and more), enter the domain, click **Install**, then point the domain's A record at the server. See [what each app does](/docs/app-catalog) and how much memory it needs before you choose.

**New apps stay locked until you finish setup.** Visitors see "This app is being set up". On the website's **Docker** tab you open the app just for yourself, create the admin account, then open it to everyone. That solves the last mistake in the table above.

**Letting customers use Docker.** If you sell hosting, set the maximum number of apps in **Packages → Docker apps** (`0` means none). Each app's memory limit counts toward the package's RAM, and installs that would exceed it are refused. Customers can only install catalog apps. Custom images are for administrators, and can only be enabled as root on the server:

```bash
zopanel ctl feature enable custom-docker
```

That switch is deliberately missing from the web interface, so a stolen panel session cannot launch arbitrary containers. Containers that customers build themselves also lose nearly every capability, use Docker's default seccomp and AppArmor profiles, and never see the Docker socket or host folders. The reasoning behind this design is in our post on [secure hosting panel architecture](/blog/secure-hosting-panel-architecture).

## How much RAM do Docker apps need on a VPS?

Add up the apps' memory limits and allow roughly 1 GB more for the system and the panel. Some reference points from ZoPanel's catalog: Vaultwarden and Memos 256 MB, Uptime Kuma and Gitea 512 MB, n8n and Nextcloud 1 GB, Metabase 1.5 GB, Open WebUI 2 GB. A 2 GB VPS comfortably runs Uptime Kuma, Vaultwarden and a few websites; for n8n or Nextcloud, start at 4 GB. If n8n is your goal, our guide to [self-hosting n8n](/blog/self-host-n8n) goes deeper.

## Conclusion

Docker doesn't make a VPS less secure, but its defaults were designed for a developer laptop, not an internet-facing server. Keep ports local, turn on user namespaces, limit resources, block internal networks and lock new apps until the admin exists: those five habits prevent most of the incidents we see.

If you would rather not maintain iptables rules and `daemon.json` yourself, ZoPanel's Free plan (3 hosting accounts, 10 websites, 10 databases, no time limit) already includes the App Store and the Docker isolation described above. Install it with `curl -fsSL https://get.zopanel.net | sudo bash` on Ubuntu 22.04/24.04 or Debian 12/13, and see [pricing](/pricing) when you need more.

## Frequently asked questions

### Does UFW block Docker ports?

Not for ports published the default way. Docker inserts its own iptables rules, and traffic is forwarded into the container before UFW's rules are checked. The simplest fix is to publish on 127.0.0.1 (`-p 127.0.0.1:8080:80`) and expose the app through nginx.

### Is rootless Docker better than userns-remap?

Both mean that root inside a container is not root on the host. Rootless mode also runs the daemon itself as an ordinary user, which is safer but brings limits around networking and low ports. userns-remap keeps the daemon as root but is more compatible, which makes it the practical choice for a server running many apps.

### Should I add my user to the docker group?

Only if you accept that the user is effectively root. Docker's documentation states that the `docker` group grants root-level privileges. On a server with several users or customers, leave Docker to root or to the panel that manages it.

### Can I run Docker apps next to WordPress sites on the same VPS?

Yes, as long as each container has a memory limit and the total still leaves room for PHP, the database and the web server. What you must avoid is a container with no memory limit, because one leaking app can take every website down with it.

### Can hosting customers install Docker apps on ZoPanel?

Yes, when their package allows it (**Packages → Docker apps**). They can only install catalog apps, each app's memory counts toward the package's RAM, and containers run inside the account's own resource limits.
