Portainer CE
A Conn3x circuit that runs Portainer Community Edition on your hosts: the web UI for looking after containers, their images, volumes and networks, and for deploying stacks, without typing podman over SSH. It can also run the Portainer agent, so one Portainer server looks after several hosts.
Both run from Portainer’s official images under Podman as systemd services, pinned by tag and digest. The server listens on 127.0.0.1:9443, HTTPS only, for an SSH tunnel or a reverse proxy in front. The first apply creates the admin account from a password you pass at run time, so nobody can claim the instance before you open it. It runs on Debian, Ubuntu, Fedora and Rocky Linux.
Podman, not Docker
Section titled “Podman, not Docker”Portainer manages the host’s Podman, through Podman’s Docker-compatible API socket, /run/podman/podman.sock. The circuit turns that socket on and mounts it into the containers where Portainer looks for Docker’s. Portainer has supported Podman environments since 2.20. Swarm, which needs Docker, isn’t there.
You don’t need Docker, and this circuit doesn’t install it. If a host already runs Docker, from circuit-docker or elsewhere, this circuit isn’t the way to put Portainer on it: Portainer would see Podman’s containers, not Docker’s. Running both engines on one host is rarely what you want anyway.
Portainer’s own docs name CentOS Stream 9 with Podman 5 as the setup they support. Rocky Linux 10, Fedora 45 and Debian 13 ship Podman 5, and Ubuntu 24.04 ships Podman 4.9. All four pass the checks under Tested on, but Portainer doesn’t promise Ubuntu’s older Podman.
variable "portainer_password" { type = string ephemeral = true sensitive = true}
module "portainer" { source = "github.com/conn3xorg/circuit-portainer?ref=v0.1.0"
hosts = { apps = "192.168.1.50" } ssh_private_key_file = "~/.ssh/id_ed25519"
admin_password = var.portainer_password}Then run conn3x init and conn3x apply with the password in the environment:
export TF_VAR_portainer_password='...'conn3x applyA second conn3x plan says No changes. Every plan needs the password too, since Conn3x checks the file it’s kept in on the host against it. Portainer wants at least 12 characters, and the circuit checks that before it connects to anything.
Reach the UI over SSH: ssh -L 9443:127.0.0.1:9443 root@192.168.1.50, then open https://localhost:9443 and sign in as admin. Your browser warns about the certificate the first time. Portainer made it for itself on its first start, and it’s in /var/lib/portainer/data/certs if you’d like to check its fingerprint.
To try it on a throwaway container first, see examples/local.
Behind a reverse proxy
Section titled “Behind a reverse proxy”For a name and a real certificate, put a proxy in front, such as circuit-caddy on the same host. Portainer only speaks HTTPS here, so the proxy talks HTTPS to it and skips the check of Portainer’s self-signed certificate on that loopback hop:
module "caddy" { source = "github.com/conn3xorg/circuit-caddy?ref=v0.3.0"
hosts = { apps = "192.168.1.50" } ssh_private_key_file = "~/.ssh/id_ed25519"
sites = { "portainer.example.com" = { extra = "reverse_proxy https://127.0.0.1:9443 {\n transport http {\n tls_insecure_skip_verify\n }\n}" } }}Skipping the check is only safe because that hop never leaves the host.
More hosts, with the agent
Section titled “More hosts, with the agent”One server can look after many hosts. On each of the others, run only the agent, listening on an address the server can reach:
variable "agent_secret" { type = string ephemeral = true sensitive = true}
module "portainer_server" { source = "github.com/conn3xorg/circuit-portainer?ref=v0.1.0"
hosts = { apps = "10.0.0.5" } ssh_private_key_file = "~/.ssh/id_ed25519"
admin_password = var.portainer_password agent_secret = var.agent_secret}
module "portainer_agent_web1" { source = "github.com/conn3xorg/circuit-portainer?ref=v0.1.0"
hosts = { web1 = "10.0.0.11" } ssh_private_key_file = "~/.ssh/id_ed25519"
server = false agent = true agent_listen_address = "10.0.0.11" agent_secret = var.agent_secret}agent_listen_address is one address for every host in a module, so each agent host gets its own module, or you use "0.0.0.0" with a firewall that only lets the server in on port 9001. Then add each host in Portainer under Environments, Add environment, as an agent at 10.0.0.11:9001. The agent_address output gives that address.
agent_secret sets AGENT_SECRET on the server and the agents. Without it, an agent belongs to the first Portainer server that connects to it, and an agent nobody has claimed stops after 72 hours (systemd starts it again). With it, an agent only answers a server holding the same secret. The agent speaks TLS with a certificate it makes for itself; it doesn’t need a proxy.
What it does
Section titled “What it does”On each host, in order:
- Installs Podman and turns on
podman.socket, the Docker-compatible API Portainer talks to. - With
agent, creates/var/lib/containers/storage/volumesif Podman hasn’t yet, since the agent mounts it. - With
server, creates/etc/portainer, mode0700, and writesadmin_passwordto/etc/portainer/admin_password, mode0600, write-only: the plan and state hold an HMAC of it, never the password. Creates/var/lib/portainer/datain a parent only root can enter. - Through circuit-podman v0.1.0, runs
docker.io/portainer/portainer-ce:VERSION@DIGESTasportainer.serviceand, withagent,docker.io/portainer/agent:VERSION@DIGESTasportainer-agent.service. Both are Quadlet units on the host’s network with Podman’s socket mounted.agent_secretgoes to/etc/containers/env/NAME.env, mode0600, from an ephemeral value. - Waits up to 10 minutes for the server’s and the agent’s ports to open, on the first apply and again after any change that restarts them, so the apply finishes with Portainer answering.
The server starts with --http-disabled --bind-https LISTEN:PORT --tunnel-addr LISTEN --tunnel-port TUNNEL_PORT --admin-password-file ... -H unix:///var/run/docker.sock. So HTTP stays off, the Edge tunnel listens on listen_address like the UI, the admin is created on the first start, and the host’s own Podman is Portainer’s first environment.
A change to an input restarts the containers it touches.
Who can do what
Section titled “Who can do what”Anyone who signs in to Portainer as an admin can start a privileged container with the host’s / mounted, which makes them root on the host. The same is true of whoever reaches an unclaimed agent first. That’s what managing a container engine means, not a flaw in Portainer, but it’s why the defaults keep everything on 127.0.0.1, why the admin is created by the apply, and why agent_secret exists. Treat the admin password like the host’s root password.
The containers run with SELinux separation off (SecurityLabelDisable=true), since Podman’s socket is labelled for Podman alone. A container holding that socket can already do anything on the host, so the label wouldn’t hold it back.
Upgrades
Section titled “Upgrades”Change portainer_version, image_digest and agent_image_digest together. conn3x plan shows the new images in the units, and the apply restarts Portainer on them; Portainer migrates its database as it starts. CI applies 2.45.2 first and then upgrades to 2.45.3. Keep the server and agents on the same version.
What it doesn’t do
Section titled “What it doesn’t do”- It doesn’t change the admin password after the first start. Portainer reads the file once. Change it in Portainer under My account.
- It doesn’t set up TLS with your own certificate or a reverse proxy. Portainer serves its own self-signed certificate.
- It doesn’t open a port in a firewall.
- It doesn’t run the Edge agent or anything for Kubernetes.
- It doesn’t back anything up. Back up
/var/lib/portainer/data(see circuit-restic). - Deleting the module leaves Podman, the containers and
/var/lib/portainerin place.
Inputs
Section titled “Inputs”| Name | Type | Default | Description |
|---|---|---|---|
hosts |
any | Required | Hosts to configure, by name. Each value is an address, such as "192.168.1.50", or a host object such as { address = "192.168.1.50", host_key = "ssh-ed25519 AAAA..." }. A host object can also set user, port, private_key_file and jump_hosts. The two forms can be mixed. |
ssh_user |
string | "root" |
User to log in as. |
ssh_port |
number | 22 |
SSH port. |
ssh_private_key_file |
string | null |
Path to a private key. When null, Conn3x uses the SSH agent. |
ssh_known_hosts_file |
string | null |
known_hosts file to check host keys against. When null, ~/.ssh/known_hosts. Not used for a host object with host_key. |
ssh_become |
bool | false |
Run commands as root through sudo. Set it when ssh_user isn’t root. |
server |
bool | true |
Run the Portainer server. |
agent |
bool | false |
Run the Portainer agent. |
admin_password |
string, sensitive, ephemeral | null |
Password for the admin user, created on the first start. Needed with server. At least 12 characters. Ephemeral and sensitive: it reaches a root-only file on the host but never the plan or state. |
agent_secret |
string, sensitive, ephemeral | null |
AGENT_SECRET for the server and agent, at least 16 characters. Ephemeral and sensitive, like admin_password. |
listen_address |
string | "127.0.0.1" |
Address the server’s UI and Edge tunnel listen on. |
port |
number | 9443 |
Port of the server’s HTTPS UI and API. |
tunnel_port |
number | 8000 |
Port of the server’s Edge tunnel. |
agent_listen_address |
string | "127.0.0.1" |
Address the agent listens on. Set an address the server can reach. |
agent_port |
number | 9001 |
Port the agent listens on. |
portainer_version |
string | "2.45.3" |
Tag of both images. Change both digests with it. |
image_digest |
string | "sha256:3609c307..." |
Digest of docker.io/portainer/portainer-ce for portainer_version. Find it on Docker Hub or with skopeo inspect docker://docker.io/portainer/portainer-ce:TAG. |
agent_image_digest |
string | "sha256:6e6d2d5d..." |
Digest of docker.io/portainer/agent for portainer_version. |
Outputs
Section titled “Outputs”| Name | Description |
|---|---|
services |
The systemd services the circuit runs, portainer.service and portainer-agent.service. |
url |
Where the server answers on the host, such as https://127.0.0.1:9443, or null without server. |
agent_address |
The address to add the agent at in Portainer, such as 10.0.0.11:9001, or null without agent. |
images |
The images the hosts run, with tags and digests. |
Requirements
Section titled “Requirements”Requires Conn3x v0.7.0 or later and systemd on each host. The images are built for amd64 and arm64. Each host needs to reach its package mirror and Docker Hub. conn3x init fetches circuit-podman from GitHub.
Tested on
Section titled “Tested on”Rocky Linux 10, Fedora 45, Ubuntu 24.04 and Debian 13. CI applies examples/local, a server and an agent on one host, to each on amd64. It applies Portainer 2.45.2 first and checks it, then upgrades to 2.45.3 and checks that the next plan has no changes. Through an SSH tunnel, trusting only Portainer’s own certificate, the check signs in as admin and sees a wrong password refused, lists the host’s containers through Podman’s socket, adds the agent as a second environment and reads the engine’s details through it. The passwords aren’t in state. Then CI restarts the host, runs the check again and plans clean.
By hand, on two DigitalOcean droplets with 2 GB of memory, Ubuntu 24.04 and Rocky Linux 10 with SELinux enforcing, each running this circuit next to circuit-passbolt and circuit-semaphore: the same upgrade from 2.45.2, the same checks with agent_secret set, a reboot after which everything came back on its own, checked again and planned clean. The Rocky audit log had no SELinux denials, and no password or secret was in state.
License
Section titled “License”MPL-2.0. See LICENSE.
Portainer Community Edition is under the zlib licence. Portainer is a trademark of Portainer.io; this circuit isn’t affiliated with Portainer.io.
Source
Section titled “Source”- Repository: conn3xorg/circuit-portainer. This page covers v0.1.0; the repository’s tags list every release.
- Examples: examples/local.
- Related circuits: Docker, Caddy, Podman, restic, k3s, kubeadm, MicroShift, Rancher, containerd, Nomad, Helm, Istio, Flux, Dockge.