Rocky Linux to Docker Host: Cockpit, Docker Engine, and Portainer from a Fresh Install

Rocky Linux to Docker Host: Cockpit, Docker Engine, and Portainer from a Fresh Install

A fresh Rocky Linux VM sits in your hypervisor doing nothing. You know what it needs to become , a Docker host you can actually operate . The default documentation assumes you already know which pieces belong together, in what order, and why the ones that aren't mentioned still matter. It doesn't cover the directory layout that keeps your volumes from becoming unmanageable six months from now, and it definitely doesn't tell you what the firewall is silently dropping.

This build takes you from a clean Rocky Linux 9 install to a working Docker host: system updates, Cockpit for lightweight web management, Docker Engine with Compose, a /working directory structure that scales, and Portainer deployed as a stack so you can manage everything from one interface. SELinux stays on. The firewall stays on. Both get addressed where they matter.

Jump to Commands Reference →


Build Guide

Starting Clean

Before anything else, the base OS needs to be current. Skipping this step means you're layering packages and repos on top of an unknown delta between the install image and what's actually shipped since. On Rocky Linux, that gap can include kernel and SELinux policy updates that affect how Docker interacts with the host later.

Run the full system update and let it finish completely. If the kernel is updated, a reboot is required before continuing — the running kernel and the updated one will diverge in ways that cause subtle problems.

sudo dnf update -y
sudo reboot

Reconnect via SSH after it comes back up.


Installing Cockpit

Cockpit gives you system visibility — CPU, memory, storage, services, and a terminal — without SSH for every check. It's not a replacement for SSH, but it cuts down on the noise for routine monitoring and service restarts.

Install Cockpit from the standard Rocky repos, then enable and start the socket:

sudo dnf install cockpit -y
sudo systemctl enable --now cockpit.socket

Cockpit runs on-demand via a socket rather than a persistent daemon. The socket activates the service on first connection, which is fine for a management host.

Open port 9090 through the firewall:

sudo firewall-cmd --add-service=cockpit --permanent
sudo firewall-cmd --reload
sudo firewall-cmd --list-all

The --permanent flag writes the rule to the persistent config. Without it, the rule disappears on the next firewall reload or system restart. Always pair it with --reload to activate immediately.

Verify by navigating to https://your-server-ip:9090 in a browser. Cockpit uses a self-signed cert by default — expect the browser warning. Log in with your OS credentials.


Installing Docker Engine

Rocky Linux does not ship Docker. The version in EPEL is not Docker Engine — it's Podman under a compatibility shim. Install from Docker's own repository to get the actual Docker Engine, containerd, and the Compose plugin as a unified, supported stack.

Install the repo management tool and add Docker's official CentOS repository. Rocky Linux 9 is binary-compatible with CentOS Stream 9, so the CentOS repo is the correct one:

sudo dnf install -y yum-utils
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo

Install Docker Engine, the CLI, containerd, and the Buildx and Compose plugins:

sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

The docker-compose-plugin installs Compose as a Docker CLI subcommand (docker compose), not as a standalone binary. This is the current approach — the older standalone docker-compose binary is a different thing and not what you want here.

Enable and start the Docker daemon, then verify:

sudo systemctl enable --now docker
docker version
docker ps

docker version should show both Client and Server entries. docker ps should return an empty container list with headers — no errors.

If you're not running as root, add your user to the docker group — otherwise every docker command requires sudo:

sudo usermod -aG docker $USER

Group membership changes don't apply to the current session. Log out and back in before expecting this to work.


Building the Working Directory Structure

This step gets skipped in most Docker guides. That's why most Docker hosts turn into a mess. Containers multiply, bind mounts scatter across the filesystem, and backups never cover everything because nobody knows where everything lives.

The structure below puts all container data under a single root — /working — that's separate from the OS. If you ever add a second disk to this host, you mount it at /working and your data is cleanly separated from system storage without any restructuring.

sudo mkdir -p /working/docker
sudo mkdir -p /working/docker-backups
sudo mkdir -p /working/shares
sudo mkdir -p /working/downloads

Each container stack will live under /working/docker/<container_name>/. All bind mounts in Compose files reference paths inside that tree. Docker does not create host directories automatically — if a path in a volume mount doesn't exist, the container either fails to start or creates the directory owned by root, which then causes permission errors you'll chase for longer than it's worth.

Set ownership on the entire tree (replace youruser with your actual username):

sudo chown -R youruser:youruser /working

This lets you create and manage stack directories without sudo for every file operation.


Deploying Portainer

Portainer is the management layer for everything that runs on this host. Stacks get deployed through its UI, which means compose files stay in one place, you can see container state without SSH, and you're not relying on memory for which docker compose up you ran six weeks ago.

Portainer itself is deployed as a stack — it manages itself the same way it manages everything else.

Create the Portainer directory under the working tree before touching the Compose file:

mkdir -p /working/docker/portainer/data

The data subdirectory is where Portainer stores its database. It needs to exist before the container starts. On Rocky Linux with SELinux enforcing, a missing directory here produces a startup failure that surfaces as a generic permission error — creating the directory first eliminates that ambiguity.

Create docker-compose.yml at /working/docker/docker-compose.yml. The easiest options are to create it directly on the server with nano:

nano /working/docker/docker-compose.yml

Or if you prefer a GUI, connect to the server via WinSCP and copy the file into /working/docker/. Either way, the content is the same:

version: "3.9"

services:
  portainer:
    image: portainer/portainer-ce:latest
    container_name: portainer
    restart: always
    ports:
      - "9443:9443"
    volumes:
      - /working/docker/portainer/data:/data:Z
      - /var/run/docker.sock:/var/run/docker.sock
    networks:
      - proxy

networks:
  proxy:
    external: true

Three things worth noting here. The :Z label on the data volume tells SELinux this mount is private to this container — without it, SELinux silently denies writes to the bind mount and Portainer fails to initialize its database with no obvious error message in the container logs. The Docker socket mount gives Portainer visibility into the local Docker environment. The proxy network is expected to be a pre-built external bridge — create it before deploying if it doesn't exist yet:

docker network create proxy

Deploy the stack and verify the container comes up:

cd /working/docker
docker compose up -d
docker ps

You should see portainer in the list with status Up. If it shows Restarting, check the logs:

docker logs portainer

Portainer is reachable on port 9443 without a firewalld rule because the stack uses an external bridge network — Docker handles its own iptables routing for bridge-networked containers. A firewall-cmd rule for 9443 would only be needed if the container were running in host network mode.


Initial Portainer Setup

Navigate to https://your-server-ip:9443 and accept the self-signed certificate warning. Create the admin account on first load. Portainer has a bootstrap timeout — if you don't complete this step within a few minutes of first access, the container needs a restart:

docker restart portainer

Select Local Docker Environment on the environment setup screen. This connects Portainer to the Docker socket you mounted in the Compose file.

Verify firewall state before considering the build done:

sudo firewall-cmd --list-all
sudo firewall-cmd --list-all --permanent
sudo firewall-cmd --list-ports

You should see cockpit in the services list. If it's missing in the --permanent output but present at runtime, the rule won't survive a reload.

From here, additional stacks deploy through Portainer's UI under Stacks → Add Stack. Paste a Compose file, set any environment variables, deploy. The compose file is stored in Portainer's database — not on disk separately — so the /working/docker/<container_name>/ directory structure handles bind mount paths, while Portainer handles the stack definitions.


Commands Reference

# System Update
sudo dnf update -y
sudo reboot

# Cockpit
sudo dnf install cockpit -y
sudo systemctl enable --now cockpit.socket
sudo firewall-cmd --add-service=cockpit --permanent
sudo firewall-cmd --reload
sudo firewall-cmd --list-all

# Docker Engine
sudo dnf install -y yum-utils
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker

# Verify Docker
docker version
docker ps

# Optional: Docker group (log out and back in after)
sudo usermod -aG docker $USER

# Working Directory Structure (replace youruser)
sudo mkdir -p /working/docker
sudo mkdir -p /working/docker-backups
sudo mkdir -p /working/shares
sudo mkdir -p /working/downloads
sudo chown -R youruser:youruser /working

# Portainer Directories
mkdir -p /working/docker/portainer/data

# Portainer Compose File
# nano /working/docker/docker-compose.yml
# (or copy via WinSCP to /working/docker/docker-compose.yml)
cd /working/docker

# Create External Network (if not exists)
docker network create proxy

# Deploy Portainer
docker compose up -d
docker ps

# Firewall Verification
sudo firewall-cmd --list-all
sudo firewall-cmd --list-all --permanent
sudo firewall-cmd --list-ports
sudo firewall-cmd --list-ports --permanent

Ryan Mckernan
Author

Ryan Mckernan

IT service manager & infrastructure architect turning real-world IT messes into practical, documented fixes. I build systems, streamline ops, and share field notes at Painfully Useful—tested, refined, and repeatable.