Securing Portainer CE

Portainer has the Docker socket, which means whoever logs in to it controls the whole server. Treat its login page like a root SSH login, because that's roughly what it is.

Why Portainer needs care

The standard install mounts /var/run/docker.sock into the Portainer container. Anything that can talk to that socket can start a container with the host's / mounted inside and change any file on the server. Docker's own security docs say only trusted users should control the daemon.

So a Portainer login is effectively root on the host. An attacker who gets into Portainer doesn't need an SSH key.

Know the ports

From Portainer's install and requirements docs:

The documented install command publishes 8000 and 9443 on every interface:

docker volume create portainer_data
docker run -d -p 8000:8000 -p 9443:9443 --name portainer --restart=always \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v portainer_data:/data portainer/portainer-ce:lts

Docker-published ports skip UFW, so ufw deny 9443 won't close them. See Docker bypasses UFW.

Finish setup in the first five minutes

A new Portainer instance has no admin. Whoever creates the first user owns it. Portainer protects this window in two ways:

Get the token right after you start the container:

docker logs portainer 2>&1 | grep setup_token=

Then create the admin. The password must be at least 12 characters. If you miss the window, restart the container to get a new one:

docker stop portainer
docker start portainer

Don't use --no-setup-token on a public server. Portainer's docs limit it to networks you fully control. For scripted installs, set the admin password at startup with --admin-password-file instead, so there's no unclaimed window at all.

Publish less

Most single-server setups only need the UI. Change the run command so that:

To change the ports of an existing container, remove it and run it again with the new flags. Your settings live in the portainer_data volume, so they stay:

docker stop portainer
docker rm portainer
docker run -d -p 127.0.0.1:9443:9443 --name portainer --restart=always \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v portainer_data:/data portainer/portainer-ce:lts

Reach it by tunnel or allowlist

With 9443 on loopback, open an SSH tunnel from your own machine:

ssh -L 9443:127.0.0.1:9443 [email protected]

Then browse to https://localhost:9443. Nothing about Portainer is visible from the internet, and your SSH key is the only way in.

If you need it public, allow only known addresses. A provider firewall is the simplest. On the server itself, use DOCKER-USER, matching the original port through conntrack:

# eth0 = your public interface
iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 9443 -j DROP
iptables -I DOCKER-USER -i eth0 -s 203.0.113.7 -p tcp -m conntrack --ctorigdstport 9443 -j RETURN

The second rule is inserted above the first, so 203.0.113.7 gets through and everyone else is dropped. These rules need re-applying after a reboot, as the Docker guide explains.

Keep the agent and the Docker API private

The Portainer Agent also has the Docker socket, and it listens on 9001. Only your Portainer Server needs to reach it. Allow just that server's address, the same way as above, and drop the rest:

iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 9001 -j DROP
iptables -I DOCKER-USER -i eth0 -s 198.51.100.20 -p tcp -m conntrack --ctorigdstport 9001 -j RETURN

If you set a custom AGENT_SECRET on the server, pass the same value to every agent. If the server can't reach 9001 at all, Portainer's docs point you to the Edge Agent, which needs no open ports on the agent side.

Don't solve remote access by exposing the Docker daemon itself over TCP. Docker's docs state that exposing the daemon API over HTTP without TLS is not permitted, and that a firewall alone may not be enough to protect it.

Keep your SSH rule while you change firewall rules. The DOCKER-USER rules above only affect container traffic, but if you also tighten UFW or a provider firewall in the same session, make sure port 22 stays open from where you are. Test a new SSH login before you close the old one.

Use a real certificate and stay current

The self-signed certificate encrypts traffic, but your browser can't tell it apart from an attacker's. To use your own, mount the files and add --sslcert and --sslkey to the end of the run command. Portainer's docs say the certificate must be the full chain, in PEM format:

  -v /etc/portainer/certs:/certs:ro \
  portainer/portainer-ce:lts --sslcert /certs/fullchain.pem --sslkey /certs/privkey.pem

Over an SSH tunnel, the self-signed one is fine: the tunnel already proves which server you reached.

Update regularly. Take a backup first (Portainer's general settings have a backup section), then stop and remove the container, pull the new image and run it again with your usual flags:

docker stop portainer
docker rm portainer
docker pull portainer/portainer-ce:lts

Stenvakt finds the open ones for you. Its Review is a read-only security and health check of every server, including the container ports that are reachable from the internet. You get a Preview of every fix before it runs, and fixes run in safe mode: if a change cuts off your SSH, the server rolls it back by itself. It's a Mac app and isn't out yet: get early access.

Sources

  1. Portainer docs: Install Portainer CE with Docker on Linux
  2. Portainer docs: Upgrade on Docker standalone (backup, own SSL certificate)
  3. Portainer docs: "Your Portainer instance has timed out for security purposes"
  4. Portainer docs: Setup token
  5. Docker docs: Docker security (daemon attack surface)
Next guideSafe changes on a shared server