Once you have more than a couple of containers running on a VPS, docker ps in a cramped SSH window stops being fun. You forget which volume belongs to which service, you paste the wrong docker logs command, and restarting a stuck container means squinting at container IDs. A web UI that lists every container, stack, image, and volume - with live logs and a restart button next to each one - turns that chaos into a dashboard.
Portainer is the most popular self-hosted Docker management UI. The Community Edition is free and open source, runs as a single container, and gives you a full graphical view of your Docker host: start/stop/restart, live console access into any container, log streaming, image pulls, volume management, and Compose-style "stacks" you can deploy from the browser.
This guide sets it up the right way on a single VPS: Docker, a reverse proxy with automatic HTTPS, the initial-admin gotcha that trips up almost everyone, the security implications of the Docker socket, and backups.
portainer.example.com at your serverdocker-compose.ymlportainer_data volume dailyTotal time: about 15 minutes.
80 and 443 open to the internet (Let's Encrypt needs them)Portainer itself is tiny - it idles around 40 MB of RAM. It manages your containers; it does not run them inside itself, so it adds almost no overhead to the host.
In your DNS provider, add an A record:
portainer.example.com → YOUR_VPS_IPV4
Add an AAAA record too if you use IPv6. Verify it resolves before going further:
dig +short portainer.example.com
DNS has to resolve first, or Caddy can't complete the Let's Encrypt challenge.
On a fresh Ubuntu box:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Verify:
docker --version
docker compose version
If you use UFW:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Caddy needs 80 for the ACME HTTP challenge and 443 for HTTPS. Do not open Portainer's own ports (9000, 9443) to the internet - the reverse proxy handles all external traffic.
sudo mkdir -p /opt/portainer
cd /opt/portainer
sudo mkdir -p caddy-data caddy-config
Portainer's own state lives in a named Docker volume (portainer_data), which we'll define in the Compose file. The Caddy directories hold your TLS certificates.
Create /opt/portainer/docker-compose.yml:
services:
portainer:
image: portainer/portainer-ce:lts
container_name: portainer
restart: unless-stopped
command: -H unix:///var/run/docker.sock
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- portainer_data:/data
networks:
- portainer-net
caddy:
image: caddy:2
container_name: portainer-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
networks:
- portainer-net
volumes:
portainer_data:
networks:
portainer-net:
A few notes:
docker.sock mount is what lets Portainer see and control Docker. We mount it read-only (:ro) - Portainer works fine that way for the vast majority of operations, and it's a meaningful safety reduction. If you later need features that write to the socket in ways the read-only mount blocks, you can drop the :ro, but start locked down.portainer_data holds your users, settings, and stack definitions. Back it up.9000 and 9443 stay internal. Caddy reaches Portainer over the Docker network on port 9000 (plain HTTP), then terminates TLS itself with a real Let's Encrypt certificate.Create /opt/portainer/Caddyfile:
portainer.example.com {
encode zstd gzip
reverse_proxy portainer:9000
}
That's the whole file. Caddy requests a Let's Encrypt certificate the first time it starts.
9443. That is Portainer's own self-signed HTTPS endpoint, and putting Caddy's real certificate in front of a self-signed backend causes confusing TLS errors. Proxy to the plain HTTP port 9000 and let Caddy handle certificates.
cd /opt/portainer
sudo docker compose up -d
sudo docker compose logs -f
Watch the Caddy logs until the certificate is issued, then open https://portainer.example.com right away.
sudo docker restart portainer, then reload the page and create the admin immediately. Have your password manager open before you visit the page.
On first load you'll be asked to create the administrator account. Pick a username and a strong password (12+ characters). There is no public registration - this is the only way in.
After that, choose Get Started to manage the local Docker environment (the socket you mounted). Portainer drops you on the dashboard showing your host's containers, images, volumes, and networks.
The left sidebar is the whole product:
docker-compose.yml. You can paste Compose YAML directly into the browser and deploy it, no SSH required.Anything you were doing with docker and docker compose on the command line now has a button.
This is where Portainer earns its keep. Instead of SSHing in to run Compose, you deploy from the browser and Portainer keeps the definition on file.
Go to Stacks, then Add stack. Give it a name like whoami, choose the Web editor, and paste a Compose file:
services:
whoami:
image: traefik/whoami
container_name: whoami
restart: unless-stopped
networks:
- portainer-net
networks:
portainer-net:
external: true
Click Deploy the stack. Portainer pulls the image, starts the container, and now it shows up in the stack list where you can update, restart, or tear it down with one click.
To expose a new app to the internet, add another site block to your Caddyfile and reload Caddy:
portainer.example.com {
encode zstd gzip
reverse_proxy portainer:9000
}
whoami.example.com {
encode zstd gzip
reverse_proxy whoami:80
}
cd /opt/portainer
sudo docker compose restart caddy
Because both the Caddy container and your stack containers share the external portainer-net network, Caddy can reach any of them by container name. That's the pattern for hosting a whole fleet of apps behind one reverse proxy.
This is the most important section in the guide, so don't skim it.
Mounting /var/run/docker.sock into Portainer gives Portainer the ability to control the Docker daemon. On a normal Linux host, the Docker daemon runs as root, and anyone who can talk to the socket can start a container that mounts the host's root filesystem - which is effectively root on the host. Portainer's console and stack features use exactly this power on purpose; it's the whole point.
The practical consequences:
Given the stakes, spend five minutes here.
Restrict the whole site to your IPs. If you have a static office IP or a VPN exit, gate the entire hostname in Caddy:
portainer.example.com {
encode zstd gzip
@blocked not remote_ip 203.0.113.10 198.51.100.0/24
respond @blocked 403
reverse_proxy portainer:9000
}
Swap in your own IPs and CIDRs. Everyone else gets a flat 403.
Put it behind a VPN instead. The cleanest option is to not expose Portainer publicly at all. If you run Tailscale or WireGuard, bind Portainer to the private interface and reach it over the tunnel. No public login page, no brute-force surface.
Enable internal authentication hardening. In Portainer, go to Settings, then Authentication. You can raise the minimum password length and, on the account itself, turn on the built-in TOTP-based two-factor auth under your user profile. Enable it.
Create least-privilege users. Don't hand out the admin account. Under Users, create standard users and use Environments and Teams with role-based access so a teammate who only needs to restart one app can't nuke the host.
Everything Portainer knows - users, settings, stack definitions, registry credentials - lives in the portainer_data volume. The containers it manages have their own volumes; this one is just Portainer's brain. It's small but annoying to rebuild.
Create /usr/local/bin/portainer-backup.sh:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/var/backups/portainer"
DATE="$(date +%F)"
mkdir -p "$BACKUP_DIR"
docker run --rm \
-v portainer_portainer_data:/data:ro \
-v "$BACKUP_DIR":/backup \
alpine tar -czf "/backup/portainer-$DATE.tar.gz" -C /data .
find "$BACKUP_DIR" -name "portainer-*.tar.gz" -mtime +14 -delete
The volume is named portainer_portainer_data because Docker Compose prefixes the volume with the project directory name (portainer). Confirm the exact name with docker volume ls and adjust if yours differs.
Make it executable and schedule it daily:
sudo chmod +x /usr/local/bin/portainer-backup.sh
echo "25 3 * * * root /usr/local/bin/portainer-backup.sh" | \
sudo tee /etc/cron.d/portainer-backup
For off-site safety, push /var/backups/portainer to S3 or Backblaze B2 with rclone on the same schedule. Portainer also has a built-in backup under Settings, then Backup Portainer, which produces a downloadable, optionally password-protected archive - handy for a manual snapshot before an upgrade.
Portainer's LTS line is stable and upgrades are boring:
cd /opt/portainer
sudo docker compose pull
sudo docker compose up -d
Take a backup first (either the script above or the in-app backup). Because the data lives in a named volume, pulling a new image and recreating the container keeps all your settings. If a release misbehaves, pin a known-good version in the Compose file:
image: portainer/portainer-ce:2.21.4
and redeploy. Portainer does not automatically downgrade its database, so don't jump backward across major versions without reading the release notes.
"Your Portainer instance timed out for security purposes." You took too long to create the first admin. Run sudo docker restart portainer and reload the page immediately.
Caddy can't issue a certificate. DNS hasn't propagated, or your provider firewall blocks 80/443 upstream of the VPS. Confirm with dig and check the provider's firewall panel.
TLS handshake errors in Caddy's logs. You proxied to 9443 (Portainer's self-signed HTTPS) instead of 9000. Fix the Caddyfile and restart Caddy.
A stack container can't be reached by Caddy. The stack isn't on the shared network. Add the external portainer-net network to the service, or use the same network Caddy is on.
Console/exec into a container fails. Some minimal images (like scratch-based ones) have no shell. Try /bin/sh instead of /bin/bash, or accept that distroless containers simply have no interactive shell.
That's the whole setup. A small VPS, one Compose file, and fifteen minutes give you a proper Docker control panel - just remember that the panel holds the keys to the entire host, and protect it accordingly.
Need a VPS to run your Docker stack? Our Linux plans include fast NVMe storage, IPv6, and snapshots out of the box. See the options.