Portainer on a VPS: Docker in a browser
Install Portainer CE on a Docker VPS, turn an existing compose file into a Stack, and handle the socket mount, the 9443 certificate and UI drift.
What Portainer on a VPS actually does
Portainer on a VPS is a web interface for the Docker daemon that is already running on that server. A docker-compose.yml file you already have becomes a Portainer Stack: paste the YAML into the web editor, name the stack, deploy. Portainer hands that file to Docker, and the containers come up the way docker compose up -d would bring them up.
Portainer is a client of the Docker daemon. It speaks to the same socket your docker command speaks to, so a container you start from the command line shows up in the browser, and a container you start in the browser shows up in docker ps. That is the value: a container list, live logs, a shell inside a running container, and a place to edit a compose file, without keeping an SSH (secure shell) session open.
Two facts change how you install it. The Community Edition and Business Edition split decides which features you are allowed to build on. The Docker socket mount decides who effectively owns the server. Both come before the install here, on purpose.
CE and BE: name the split before you build on it
Portainer Community Edition (CE) is the free, open source build, and it is what this guide installs. Portainer Business Edition (BE) is the paid build. The features Portainer lists as Business Edition include role-based access control (RBAC), private registry management, activity audit logging, automatic backups to S3, and advanced GitOps. Those are not switches hidden behind a settings page in CE. They are absent, so a design that depends on per-user permissions or an audit trail is a design for BE.
BE has a free tier, which Portainer calls "Take 3". It covers 3 nodes, runs for an initial year, and renews at no cost while you stay at 3 nodes or fewer (checked 25 August 2026). One VPS is one node. Read the current terms on Portainer's own site before you depend on them, because licence terms change more often than the software does.
Install Portainer CE on a VPS
Docker has to be installed and running first. If it is not, start with installing Docker on a VPS and come back here.
Portainer publishes the install command on docs.portainer.io. Copy it from that page rather than from memory, because the flags move between major versions. As published on 25 August 2026 it is two commands.
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:ltsRead the flags before you run them, because two of them decide the security of the whole box.
-p 9443:9443publishes the HTTPS interface on every address the server holds, including its public one.-p 8000:8000is the tunnel server used by Edge agents. If you are not running Edge agents, leave the flag off and the port stays closed.-v portainer_data:/datais a named volume holding Portainer's own database: users, stack definitions, settings. Delete it and Portainer forgets everything.-v /var/run/docker.sock:/var/run/docker.sockhands the container the Docker API (application programming interface).--restart=alwaysstarts Portainer again after a reboot or a daemon restart.portainer/portainer-ce:ltsis a moving tag, so it changes under you on the nextdocker pull.
Confirm it is running:
docker ps --filter name=portainer
docker logs portainerIf Portainer is missing from that list, the container exited, and docker logs portainer holds the reason. For a build you can reproduce later, replace lts with the exact version tag listed for portainer/portainer-ce on Docker Hub at the moment you install, so a future pull cannot change the version underneath a config you already tested.
Then open the user interface (UI) and complete the first-run setup that Portainer's docs describe. Do that over one of the two paths below, not over a public port.
Why port 9443 shows a certificate warning
Portainer generates a self-signed certificate to secure port 9443, says so in its own install docs, and offers to let you supply your own certificate instead. Self-signed means no certificate authority your browser trusts has vouched for it, so the browser blocks the page and asks you to confirm. The traffic is still encrypted with TLS (transport layer security). What is missing is proof of identity, which means a machine sitting between you and the server could present its own certificate and the browser could not tell the difference.
Clicking through that warning once is a small risk. Clicking through it every day, on a URL that also carries root-level control of the server, trains you to dismiss the warning that one day is real. Pick one of the two fixes below instead.
Reach the UI over an SSH tunnel
Do not publish 9443 to the internet at all. Bind the published port to loopback, so only processes on the server itself can reach it.
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:ltsFrom your laptop, forward the port over SSH and leave it running while you work.
ssh -N -L 9443:127.0.0.1:9443 you@your-vpsBrowse to https://localhost:9443. To prove the port is not public, run this from a third machine, not from the VPS and not through the tunnel:
curl -k --max-time 5 https://YOUR_VPS_IP:9443/A refused connection or a timeout is the healthy result, because 127.0.0.1:9443:9443 tells Docker to listen on the loopback address only. If that command returns a page instead, the container is still publishing on every address, which means it was started without the 127.0.0.1: prefix. The browser will still warn about the certificate, because the certificate has not changed. Nobody outside the server can reach it now, which is the part that mattered.
Or put it behind a reverse proxy
If you want a trusted certificate and a real hostname, keep the loopback bind and terminate TLS in nginx on port 443.
server {
listen 443 ssl;
server_name portainer.example.com;
ssl_certificate /etc/letsencrypt/live/portainer.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/portainer.example.com/privkey.pem;
location / {
proxy_pass https://127.0.0.1:9443;
proxy_ssl_verify off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
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";
proxy_read_timeout 3600s;
}
}proxy_pass https:// is required because Portainer is listening with TLS on 9443. An http:// target sends a plain request into a TLS listener, so nginx fails to read the upstream response and returns 502 Bad Gateway. proxy_ssl_verify off is nginx's default, and it is written out here to be explicit: nginx does not check the self-signed backend certificate, which is acceptable because that connection never leaves the host.
The upgrade lines matter more than they look. Portainer's container console and its live log view are WebSocket connections. Without proxy_http_version 1.1 and the Upgrade and Connection headers, nginx sends an ordinary HTTP/1.1 request, the upgrade handshake never happens, and the console fails to connect while every other page loads normally. proxy_read_timeout 3600s replaces nginx's 60 second default, so an idle console is not cut off after a minute.
With the proxy in place, close 9443 at the firewall and leave 443 and 22 open. If any directive above is unfamiliar, the nginx reverse proxy config walkthrough explains what each one does.
The Docker socket mount is root on the host
This is the part the paste-this tutorials skip. /var/run/docker.sock is the Docker API. The Docker API can start a container. A container can be started with the host's root filesystem mounted inside it. So any process that reaches that socket can read and write every file on the server as root, which means socket access and root access are the same access.
You can watch it happen. Run this on your own server, as a normal user in the docker group:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker:cli \
docker run --rm -v /:/host alpine ls -la /host/rootThe outer container is given nothing but the socket. It uses that socket to start a second container with the host's / mounted at /host, then lists the root user's home directory, which your own user cannot read. Portainer holds exactly that socket, so Portainer holds exactly that power.
- Anyone who can log in to Portainer can do anything root can do on that server. Hand out the URL and the password with the care you would give an SSH key.
- Mounting the socket
:rodoes not help. Read-only applies to the mount in the filesystem, and a unix socket carries data both ways once a process opens it, so API calls that create containers still succeed. Treat:roondocker.sockas a comment, not a control. - The Portainer agent does not shrink the grant. It moves the UI onto a different host, and the agent on the managed host still holds that host's socket.
Every container manager that drives Docker from inside a container pays this same price. It is the reason the UI belongs behind a tunnel or a proxy rather than on an open port.
Turn a compose file into a Stack
In Portainer, a Stack is a compose file plus the containers it deployed. Adding one offers four build methods: Web editor, Upload, Git repository, and Custom template. The web editor takes docker-compose format directly, so a file that works with docker compose up -d on your own machine needs no translation. If you have not written one yet, the docker compose basics guide covers services, volumes and networks.
There is no .env file. A compose file sitting in a directory reads .env from that directory. The web editor has no directory. Variables such as ${DB_PASSWORD} are filled in from the environment variable fields on the stack form instead. Leave one blank and compose substitutes an empty string, so a database container can start with an empty password, or fail its own entrypoint check, without anything on the form looking wrong.
Relative paths do not mean what you expect. A bind mount written ./config:/config is relative to wherever Portainer wrote the file, inside its own data volume, not to a directory you chose. Portainer's docs list relative-path volume support as a Business Edition feature. On CE, use named volumes or write absolute host paths.
The stack name becomes the compose project name. Portainer labels the containers with the stack name you typed, while compose on the command line derives the project name from the directory name. If your checkout is in /opt/nextcloud-stack and the Portainer stack is called nextcloud, a later docker compose up -d in that directory builds a second, separate set of containers instead of updating the first. Pass -p nextcloud to match it, or name the directory after the stack.
Nextcloud as a Stack
Take the compose file from the Nextcloud on a VPS guide with TLS and backups rather than writing a new one, because that guide already settles the certificate and the backup schedule. In Portainer, go to Stacks, add a stack, choose the web editor, name it nextcloud, and paste the file in.
Move every secret out of the YAML and into the stack form's environment variable fields, so the pasted file holds references only:
environment:
MYSQL_HOST: db
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: ${DB_PASSWORD}Add DB_PASSWORD and the database root password as environment variables on the form, then deploy. Check the result from the command line, because the daemon is the source of truth and the UI is only a view of it:
docker compose -p nextcloud ps
docker compose -p nextcloud logs --tail=50Both commands work without a compose file present, because they select containers by the project label Portainer applied. Serve the app through the same nginx that fronts Portainer, on its own server block and its own hostname. Publishing the Nextcloud container to 127.0.0.1 and proxying it keeps one TLS termination point on the box.
Keeping git authoritative
Edit a Stack in the web editor and Portainer stores the new file in its own volume. Your git repository is not touched. That is the drift: the server and the repository now describe different things, and neither side reports a problem. The cost arrives later. Rebuild that VPS from the repository, or run docker compose up -d from a checkout, and the 2am fix someone typed into the browser is quietly undone.
Keep the repository as the input:
- Deploy with the Git repository build method rather than the web editor. Portainer clones the repository and deploys the compose file from it, so the file in git is the file that ran.
- Use the web editor to read the deployed file and to try a change. Once the change works, make the same change in git and redeploy from the repository, then discard the editor version.
- Plan for a manual redeploy on CE. Portainer's docs place the automatic GitOps update features under Business Edition, so check the docs for your own version before you design a pipeline around them.
- Record the stack name in the repository, in the README or as the directory name, so a CLI
docker compose -pcall and the Portainer stack stay on one project name.
Portainer, Cloudron and Cockpit do different jobs
Portainer manages containers on a host that already runs Docker. It does not install applications for you, and it does not manage the operating system. That boundary explains most of the comparisons people ask about.
Cloudron, CasaOS and Coolify are application platforms. You choose an app and the platform handles its database, its domain, its certificate and its backups. Portainer has App Templates, but a template is a compose file with the fields pre-filled, and after deployment it is an ordinary stack that you maintain. The comparison of Cloudron, CasaOS and Coolify covers which of those suits a given box.
Cockpit and Webmin manage the host: user accounts, storage, systemd units, package updates. Portainer knows none of that. Running Cockpit alongside Portainer on one VPS is normal, because the two barely overlap.
Backup, and the upgrade that reopens your port
Everything Portainer knows lives in portainer_data. Back it up with the container stopped, because the embedded database is written while the service runs and a copy taken part way through a write can restore into a broken state.
docker stop portainer
docker run --rm -v portainer_data:/data -v "$PWD":/backup alpine \
tar czf /backup/portainer_data.tgz -C /data .
docker start portainerRestoring that archive onto a fresh volume
The docker volume rm line destroys the current Portainer state, so take the backup above first and copy the archive off the server.
docker rm -f portainer
docker volume rm portainer_data
docker volume create portainer_data
docker run --rm -v portainer_data:/data -v "$PWD":/backup alpine \
tar xzf /backup/portainer_data.tgz -C /dataThen run your original docker run line again with the same publish flags, and Portainer starts with the users and stacks from the archive.
Upgrading replaces the container and keeps the volume. As published on 25 August 2026:
docker stop portainer
docker rm portainer
docker pull portainer/portainer-ce:lts
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:ltsdocker rm portainer deletes the container and leaves the named volume alone, so your users and stacks survive. Watch that last line. It is the vendor default, and it publishes 9443 on every address the server holds. If you bound the port to loopback earlier, put the 127.0.0.1: prefix back before you run it, or the upgrade silently reopens the UI to the internet. Run the curl check from the tunnel section again after every upgrade.
FAQ
Can Portainer deploy my existing docker-compose.yml file?
Yes. In Portainer a compose file is called a Stack. Paste the YAML into the web editor, or point the stack at a Git repository that holds the file. The format is the same docker-compose format, so nothing is rewritten. Two things change. There is no .env file, so variables come from the environment variable fields on the stack form. Relative bind mount paths such as ./config resolve inside Portainer's own data volume rather than a directory you picked, so use named volumes or absolute host paths.
Is it safe to expose Portainer's port 9443 to the internet?
Treat exposing it as exposing a root shell. Portainer needs /var/run/docker.sock, and anything that reaches that socket can start a container with the host filesystem mounted and act as root on the server. Publish the port on 127.0.0.1 and reach the UI over an SSH tunnel with ssh -N -L 9443:127.0.0.1:9443 you@your-vps, or put it behind a reverse proxy on 443 with a trusted certificate and your own access control in front of it.
Why does my browser say the Portainer certificate is not trusted?
Portainer generates a self-signed certificate for port 9443 at install time. No certificate authority has vouched for it, so browsers refuse to trust it by default. The traffic is encrypted, but the server's identity is not proven, which is what the warning is about. Fix it by supplying your own certificate to Portainer, or by terminating TLS in a reverse proxy with a certificate from a public authority and keeping 9443 on loopback.
Will Portainer interfere with containers I started from the command line?
No. Portainer is a client of the same Docker daemon, so containers created by docker run or docker compose are visible and controllable in the browser, and containers created in Portainer are visible to your docker commands. The one thing to watch is the project name. A stack Portainer deployed uses the stack name as its compose project, while the CLI defaults to the directory name, so pass docker compose -p <stackname> when you work on a Portainer stack from a shell.
Do I need Portainer Business Edition?
Only if you need what it adds. Portainer lists role-based access control, private registry management, activity audit logging, automatic backups to S3, and advanced GitOps as Business Edition features. For one administrator on one VPS, Community Edition already covers containers, stacks, logs and volumes. If you do want the Business Edition features, look at Portainer's free 3 node licence first, which covered a single VPS at no cost as of 25 August 2026.