{"id":1083,"date":"2026-11-14T13:00:00","date_gmt":"2026-11-14T13:00:00","guid":{"rendered":"https:\/\/vpslake.com\/blog\/?p=1083"},"modified":"2026-11-14T13:00:00","modified_gmt":"2026-11-14T13:00:00","slug":"install-docker-portainer-almalinux-9","status":"publish","type":"post","link":"https:\/\/vpslake.com\/blog\/2026\/11\/14\/install-docker-portainer-almalinux-9\/","title":{"rendered":"How to Install Docker Portainer on AlmaLinux 9"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Install docker portainer on almalinux 9 and manage Docker from a private HTTPS dashboard in about ten minutes. This setup uses Docker&#8217;s RPM repository, keeps Portainer data in a named volume, and avoids exposing the management UI directly to the internet.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Prerequisites<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n\n<li>A 64-bit AlmaLinux 9 VPS with at least 2 GB of RAM and 10 GB of free disk space for images, containers, and Portainer data.<\/li>\n\n\n<li>SSH access as <code>root<\/code>, or a normal account with working <code>sudo<\/code> privileges.<\/li>\n\n\n<li>Outbound HTTPS access to Docker&#8217;s package repository and Docker Hub.<\/li>\n\n\n<li>An SSH client on your workstation. A domain is not required because the dashboard will be reached through an SSH tunnel.<\/li>\n\n\n<li>A <a href=\"https:\/\/vpslake.com\/buy-vps\">VPSLake Linux VPS<\/a> if you need a root-access AlmaLinux server for this Docker setup.<\/li>\n\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">These commands use <code>sudo<\/code> for a regular SSH account. If you are logged in as <code>root<\/code>, remove <code>sudo<\/code> from the commands.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 1: Confirm AlmaLinux 9 and update the host<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Checking the release and CPU architecture first prevents a repository mismatch before Docker is installed.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>grep -E '^(PRETTY_NAME|VERSION_ID|ID_LIKE)=' \/etc\/os-release\nuname -m\nsudo dnf -y upgrade<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The release should identify AlmaLinux 9, normally with an <code>x86_64<\/code> architecture:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>PRETTY_NAME=\"AlmaLinux 9.x (Emerald Puma)\"\nVERSION_ID=\"9.x\"\nID_LIKE=\"rhel centos fedora\"\nx86_64<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The minor version and kernel release will vary as AlmaLinux publishes updates. Resolve any DNS, mirror, or signature error before continuing.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 2: Add Docker&#8217;s RHEL-compatible repository<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">AlmaLinux follows the RHEL package layout, so Docker&#8217;s RHEL repository provides the Engine packages needed here. The <a href=\"https:\/\/docs.docker.com\/engine\/install\/rhel\/\" target=\"_blank\" rel=\"noopener\">official Docker RHEL installation guide<\/a> is the reference for this repository method.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Install the DNF repository helper and add Docker&#8217;s stable repository:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo dnf install -y dnf-plugins-core ca-certificates\nsudo dnf config-manager --add-repo https:\/\/download.docker.com\/linux\/rhel\/docker-ce.repo\nsudo dnf makecache<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Confirm that DNF can see Docker packages before asking it to install them:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>dnf list --showduplicates docker-ce | tail -n 5<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You should see one or more <code>docker-ce<\/code> entries from the Docker repository. The exact version changes over time, so do not copy a version from an example into a production command.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 3: Install and test Docker Engine<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Installing the Compose plugin with the Engine gives Portainer a complete Docker host and leaves you with the standard <code>docker compose<\/code> command for future stacks.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\nsudo systemctl enable --now docker\nsudo docker version --format 'Client {{.Client.Version}} \/ Server {{.Server.Version}}'\nsudo docker compose version<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Expected output has an active client\/server pair and a Compose version, although the numbers will differ:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Client 29.x.y \/ Server 29.x.y\nDocker Compose version v2.x.y<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run Docker&#8217;s disposable test image. It checks the daemon, registry access, and container runtime without leaving a stopped test container:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo docker run --rm hello-world<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The final lines should contain <code>Hello from Docker!<\/code> and <code>Your installation appears to be working correctly.<\/code><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 4: Check firewalld and SELinux before publishing anything<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Portainer will listen only on the VPS loopback address, so you do not need an inbound 9443 or 8000 firewall rule for this single-host setup.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Check the security services without disabling either one:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>getenforce\nsudo systemctl is-active firewalld<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Typical output is:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Enforcing\nactive<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If firewalld is active, leave its existing SSH rule in place and do not add a public Portainer rule. If your VPS uses a custom SSH port, preserve that provider or host configuration; this guide does not change it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 5: Deploy Portainer CE with persistent data<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The named volume holds Portainer&#8217;s database, users, and settings separately from the container. Portainer&#8217;s <a href=\"https:\/\/docs.portainer.io\/start\/install-ce\/server\/docker\/linux\" target=\"_blank\" rel=\"noopener\">official Docker installation guide<\/a> uses the same Docker socket and data-volume pattern.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On AlmaLinux with SELinux enforcing, <code>--privileged<\/code> is the documented requirement for Portainer&#8217;s Docker-socket integration. It keeps SELinux enabled, while the loopback binding keeps the HTTPS interface private.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo docker volume create portainer_data\nsudo docker run -d \\\n  --name portainer \\\n  --restart=always \\\n  --privileged \\\n  --publish 127.0.0.1:9443:9443 \\\n  --volume \/var\/run\/docker.sock:\/var\/run\/docker.sock \\\n  --volume portainer_data:\/data \\\n  portainer\/portainer-ce:lts<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Docker prints a container ID. Check both the running state and the host-side binding:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo docker ps --filter name=portainer --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}\\t{{.Ports}}'<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>NAMES      IMAGE                       STATUS       PORTS\nportainer  portainer\/portainer-ce:lts Up ...        127.0.0.1:9443-&gt;9443\/tcp<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Portainer generates a self-signed certificate for its initial HTTPS service. A browser warning during first login is expected when you use the private tunnel below.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 6: Open Portainer through an SSH tunnel<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The tunnel forwards a local workstation port to the VPS-only listener, so the dashboard can be used without exposing 9443 or the optional Edge Agent port 8000.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run this command on your local workstation, not in the VPS shell. Replace the uppercase values and keep the terminal open:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ssh -N -L 9443:127.0.0.1:9443 YOUR_SSH_USER@SERVER_IP<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Open <code>https:\/\/127.0.0.1:9443<\/code> in your browser. Continue past the self-signed certificate warning, create a strong unique administrator password, and choose the local Docker environment.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If port 9443 is already occupied on your workstation, change only the local side:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ssh -N -L 19443:127.0.0.1:9443 YOUR_SSH_USER@SERVER_IP<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Use <code>https:\/\/127.0.0.1:19443<\/code> in that case. The VPS listener remains on 9443 and remains private.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Verify Docker and Portainer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">These checks prove that Docker starts at boot, Portainer has its restart policy, the listener is bound to loopback, and the HTTPS service answers locally:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo systemctl is-active docker\nsudo systemctl is-enabled docker\nsudo docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' portainer\nsudo ss -lntp | grep ':9443'\ncurl --insecure --head --max-time 10 https:\/\/127.0.0.1:9443<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Expected results include:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>active\nenabled\nalways\nLISTEN ... 127.0.0.1:9443 ...\nHTTP\/1.1 200 OK<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Refresh the browser through the tunnel. You should see Portainer&#8217;s setup or dashboard page and the local Docker environment. For a safer SSH baseline, you can also use VPSLake&#8217;s <a href=\"https:\/\/vpslake.com\/sshd-hardening-generator\">SSH hardening generator<\/a>.<\/p>\n\n\n\n<aside class=\"vl-element vl-cta-banner\" aria-label=\"Run Docker and Portainer\">\n\t<div class=\"vl-cta-banner__content\">\n\t\t<p class=\"vl-cta-banner__eyebrow\">AlmaLinux VPS<\/p>\n\t\t<h3 class=\"vl-cta-banner__title\">Run Docker and Portainer<\/h3>\n\t\t<p class=\"vl-cta-banner__text\">Get a root-access AlmaLinux VPS for a private Docker management dashboard.<\/p>\n\t<\/div>\n\t<a class=\"vl-cta-banner__button\" href=\"\/buy-vps\" aria-label=\"View Linux VPS plans\">View Linux VPS plans<\/a>\n<\/aside>\n\n\n\n<h2 class=\"wp-block-heading\">Troubleshooting<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\"><code>No match for argument: docker-ce<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">DNF cannot see Docker&#8217;s repository, or its metadata is stale. Inspect the enabled repositories and refresh the cache:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo dnf repolist --enabled | grep -i docker\nsudo dnf makecache\ndnf list --showduplicates docker-ce<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If no Docker repository appears, run the <code>dnf config-manager --add-repo<\/code> command from Step 2 again and confirm that the server can resolve <code>download.docker.com<\/code> over HTTPS.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><code>package podman ... conflicts with ...<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A preinstalled Podman or standalone runtime can conflict with Docker&#8217;s packages. On a new VPS, remove the conflicting package and retry; on a host already running containers, identify its workloads first rather than removing the active runtime blindly.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo dnf remove -y podman runc\nsudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Removing packages does not remove the named <code>portainer_data<\/code> volume, but do not use this fix on an established production host without a migration plan.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Portainer exits or logs an SELinux denial<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Portainer needs access to the Docker socket, and SELinux can block that access unless the container is privileged. Inspect the logs and recreate the container with the exact <code>--privileged<\/code> and loopback options from Step 5:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>getenforce\nsudo docker logs --tail 80 portainer<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Do not disable SELinux to make the dashboard start. The container can be replaced without losing settings because <code>portainer_data<\/code> is separate.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The browser cannot connect to <code>127.0.0.1:9443<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The SSH tunnel must run on your workstation and stay open. Restart it, then confirm that the VPS container is running and the service is listening:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo docker ps --filter name=portainer\nsudo ss -lntp | grep ':9443'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The port column should show <code>127.0.0.1:9443-&gt;9443\/tcp<\/code>. Do not browse to <code>https:\/\/SERVER_IP:9443<\/code>; this setup intentionally does not publish Portainer publicly.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Hardening<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n\n<li>Keep Portainer bound to loopback. If you later need team access, put it behind a reverse proxy with a real certificate, authentication, and a narrowly scoped firewalld rule.<\/li>\n\n\n<li>Treat the Docker socket, Portainer administrator account, and any Docker-admin access as root-equivalent. Limit SSH and dashboard access to trusted users.<\/li>\n\n\n<li>Back up the <code>portainer_data<\/code> volume before changing the container image, and keep enough disk space for Docker images and volumes.<\/li>\n\n\n<li>Never expose Docker&#8217;s unauthenticated TCP API on port 2375.<\/li>\n\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">FAQ<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Do I need to open port 9443 in firewalld?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. Docker publishes Portainer on <code>127.0.0.1<\/code>, not on the VPS&#8217;s public interface. The SSH tunnel carries browser traffic to that private listener, so no new inbound firewall rule is needed.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does this guide use the <code>lts<\/code> Portainer image tag?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The LTS tag favors a longer-lived stable release line for a server dashboard. The named volume means the Portainer database and settings remain available when you replace the container during a planned upgrade.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I use a domain name instead of an SSH tunnel?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, but that is a different exposure model. You would need a reverse proxy, a trusted TLS certificate, access controls, and a firewalld rule limited to the intended traffic; a domain is unnecessary for the private setup here.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What is Portainer port 8000 used for?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Port 8000 is the optional tunnel service for Portainer Edge Agents. A Portainer server managing the Docker Engine on the same VPS through <code>\/var\/run\/docker.sock<\/code> does not need that port, so this guide leaves it unpublished.<\/p>\n\n\n\n<script type=\"application\/ld+json\">{\"@context\": \"https:\/\/schema.org\", \"@type\": \"FAQPage\", \"mainEntity\": [{\"@type\": \"Question\", \"name\": \"Do I need to open port 9443 in firewalld?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"No. Docker publishes Portainer on `127.0.0.1`, not on the VPS's public interface. The SSH tunnel carries browser traffic to that private listener, so no new inbound firewall rule is needed.\"}}, {\"@type\": \"Question\", \"name\": \"Why does this guide use the `lts` Portainer image tag?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"The LTS tag favors a longer-lived stable release line for a server dashboard. The named volume means the Portainer database and settings remain available when you replace the container during a planned upgrade.\"}}, {\"@type\": \"Question\", \"name\": \"Can I use a domain name instead of an SSH tunnel?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"Yes, but that is a different exposure model. You would need a reverse proxy, a trusted TLS certificate, access controls, and a firewalld rule limited to the intended traffic; a domain is unnecessary for the private setup here.\"}}, {\"@type\": \"Question\", \"name\": \"What is Portainer port 8000 used for?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"Port 8000 is the optional tunnel service for Portainer Edge Agents. A Portainer server managing the Docker Engine on the same VPS through `\/var\/run\/docker.sock` does not need that port, so this guide leaves it unpublished.\"}}]}<\/script>\n\n","protected":false},"excerpt":{"rendered":"Install docker portainer on almalinux 9 with Docker Engine, persistent Portainer data, private HTTPS access, and checks that work on a fresh VPS.","protected":false},"author":1,"featured_media":1084,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"csco_singular_sidebar":"","csco_page_header_type":"","csco_page_load_nextpost":"","footnotes":""},"categories":[5,9],"tags":[95,119,118,137],"class_list":["post-1083","post","type-post","status-publish","format-standard","has-post-thumbnail","category-guides","category-vps-hosting","tag-almalinux-9","tag-containers","tag-docker","tag-portainer","cs-entry"],"_links":{"self":[{"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/posts\/1083","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/comments?post=1083"}],"version-history":[{"count":1,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/posts\/1083\/revisions"}],"predecessor-version":[{"id":4031,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/posts\/1083\/revisions\/4031"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/media\/1084"}],"wp:attachment":[{"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/media?parent=1083"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/categories?post=1083"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/tags?post=1083"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}