{"id":1077,"date":"2026-11-13T18:00:00","date_gmt":"2026-11-13T18:00:00","guid":{"rendered":"https:\/\/vpslake.com\/blog\/?p=1077"},"modified":"2026-11-13T18:00:00","modified_gmt":"2026-11-13T18:00:00","slug":"install-docker-compose-rocky-linux-9","status":"publish","type":"post","link":"https:\/\/vpslake.com\/blog\/2026\/11\/13\/install-docker-compose-rocky-linux-9\/","title":{"rendered":"How to Install Docker Compose on Rocky Linux 9"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Install Docker Compose on Rocky Linux 9 by adding Docker&#8217;s RPM repository, installing the Compose CLI plugin, and launching a private two-service test project. You can finish the setup and confirm that Compose works in about ten minutes.<\/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 Rocky Linux 9 VPS with at least 2 GB of RAM and 10 GB of free disk space.<\/li>\n\n\n<li>SSH access as <code>root<\/code>, or a regular Linux account with working <code>sudo<\/code> privileges.<\/li>\n\n\n<li>Outbound HTTPS access to Docker&#8217;s package repository and container registry.<\/li>\n\n\n<li>A <a href=\"https:\/\/vpslake.com\/buy-vps\">VPSLake Linux VPS<\/a> if you need a Rocky Linux server with root access.<\/li>\n\n\n<li>No domain is needed for the private test. Use a domain and a TLS reverse proxy before publishing a real application.<\/li>\n\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Rocky Linux 9 uses DNF and firewalld. The commands below use <code>sudo<\/code> for a normal SSH account; if you are logged in as root, remove <code>sudo<\/code> but keep the same order.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 1: Confirm Rocky Linux 9 and update the host<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Checking the release and architecture first keeps you from applying an Enterprise Linux repository intended for another major version.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash . \/etc\/os-release printf &#8216;%s\\n&#8217; &#8220;$PRETTY_NAME&#8221; printf &#8216;Major release: %s\\n&#8217; &#8220;$(rpm -E %rhel)&#8221; uname -m sudo dnf upgrade -y ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first lines should identify Rocky Linux 9 and a 64-bit machine:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~text Rocky Linux 9.x (Blue Onyx) Major release: 9 x86_64 ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The minor release and number of upgraded packages will vary. Resolve any DNS, mirror, or GPG-signature error before continuing.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 2: Remove conflicting container packages<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Docker&#8217;s official packages can conflict with an existing Docker or Podman installation, so clear those packages on a new or disposable VPS before adding Docker&#8217;s repository.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run this only after checking that the server does not host Podman workloads you need. DNF may say that some package names are not installed, which is harmless; Docker data under <code>\/var\/lib\/docker<\/code> is not removed by this package operation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash sudo dnf remove -y docker \\ docker-client \\ docker-client-latest \\ docker-common \\ docker-latest \\ docker-latest-logrotate \\ docker-logrotate \\ docker-engine \\ podman \\ runc ~~~<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 3: Add Docker&#8217;s RPM repository<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The RHEL-compatible Docker repository supplies Engine, Buildx, and the Compose plugin as DNF packages that can receive normal updates. This is Docker&#8217;s recommended repository approach, documented in its <a href=\"https:\/\/docs.docker.com\/engine\/install\/rhel\/\" target=\"_blank\" rel=\"noopener\">RHEL installation guide<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash sudo dnf install -y dnf-plugins-core ca-certificates sudo dnf config-manager &#8211;add-repo https:\/\/download.docker.com\/linux\/rhel\/docker-ce.repo sudo dnf makecache dnf repolist &#8211;enabled | grep -i docker dnf list &#8211;showduplicates docker-ce | tail -n 5 ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The repository check should show Docker&#8217;s stable channel, and the package list should include an <code>el9<\/code> build. If no repository or package appears, stop here and fix the repository connection before installing anything.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 4: Install Docker Engine and the Compose plugin<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Compose v2 is delivered as a Docker CLI plugin, so install it with the Engine rather than downloading the obsolete standalone <code>docker-compose<\/code> binary.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin sudo systemctl enable &#8211;now docker sudo systemctl is-active docker sudo docker version &#8211;format &#8216;Client {{.Client.Version}} \/ Server {{.Server.Version}}&#8217; sudo docker compose version ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You should see an active service and version strings. Exact versions change as Docker publishes updates:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~text active Client 29.x \/ Server 29.x Docker Compose version vX.Y.Z ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run Docker&#8217;s disposable smoke test before creating a project:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash sudo docker run &#8211;rm hello-world ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The final part of the response should include:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~text Hello from Docker! Your installation appears to be working correctly. ~~~<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 5: Allow your SSH account to run Docker<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The Docker socket is root-owned by default. Adding one trusted administrator to the <code>docker<\/code> group avoids <code>sudo<\/code>, but group membership provides effectively root-level control over the host.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Replace <code>YOUR_LINUX_USER<\/code> with the account you use for SSH:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash sudo usermod -aG docker YOUR_LINUX_USER ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Log out of SSH completely and reconnect so the new group list is loaded. Then verify access without <code>sudo<\/code>:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash id -nG docker info &#8211;format &#8216;Server {{.ServerVersion}}&#8217; ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The group list should contain <code>docker<\/code>, and <code>docker info<\/code> should print the Engine version. If you do not want permanent group access, leave the account unchanged and use <code>sudo docker compose<\/code> for the remaining commands.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 6: Create a private Compose test project<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This project runs Nginx and a small BusyBox checker. The checker reaches Nginx through Compose&#8217;s internal service name, while Nginx publishes its page only on the server&#8217;s loopback address.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Create a project directory and a page for Nginx:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash mkdir -p ~\/compose-rocky9\/site cd ~\/compose-rocky9 printf &#8216;%s\\n&#8217; &#8216;Rocky Linux 9 Docker Compose is working.&#8217; &gt; site\/index.html ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Create <code>compose.yaml<\/code>:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash tee compose.yaml &gt;\/dev\/null &lt;&lt;&#8216;EOF&#8217; services: web: image: nginx:alpine restart: unless-stopped ports:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n\n<li>&#8220;127.0.0.1:8091:80&#8221; volumes:<\/li>\n\n\n<li>.\/site:\/usr\/share\/nginx\/html:ro,Z<\/li>\n\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">checker: image: busybox:1.36 restart: unless-stopped depends_on: web: condition: service_started command: [&#8220;sh&#8221;, &#8220;-c&#8221;, &#8220;while true; do sleep 3600; done&#8221;] healthcheck: test: [&#8220;CMD-SHELL&#8221;, &#8220;wget -q -O &#8211; http:\/\/web\/ | grep -q &#8216;Rocky Linux 9 Docker Compose is working.'&#8221;] interval: 5s timeout: 3s retries: 12 EOF ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>:Z<\/code> suffix gives the private bind mount an SELinux label, and <code>127.0.0.1<\/code> prevents the test page from being reachable directly from the internet. The checker has no published port; it validates the web service over Compose&#8217;s private network. See Docker&#8217;s <a href=\"https:\/\/docs.docker.com\/reference\/compose-file\/\" target=\"_blank\" rel=\"noopener\">Compose file reference<\/a> for the service and health-check fields.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 7: Render and start the project<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Render the file before startup so YAML or interpolation errors are caught before containers are created.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash docker compose config &#8211;quiet docker compose pull docker compose up -d docker compose ps ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>config --quiet<\/code> should produce no output and return status 0. After the images download, both services should be running and the checker should become healthy:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~text NAME                         SERVICE   STATUS                   PORTS compose-rocky9-web-1         web       Up                       127.0.0.1:8091-&gt;80\/tcp compose-rocky9-checker-1     checker   Up (healthy) ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The generated names may differ if you run the project from another directory or set a Compose project name. Because port 8091 is bound to loopback, this test does not require a new firewalld rule.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Verify Docker Compose on Rocky Linux 9<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Use the host request, the internal service request, and the health table to prove that Compose can start the project and that its network works:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash curl &#8211;fail &#8211;silent &#8211;show-error http:\/\/127.0.0.1:8091 docker compose exec -T checker wget -q -O &#8211; http:\/\/web\/ docker compose ps ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first two commands should return the same page text, and the status table should keep the checker healthy:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~text Rocky Linux 9 Docker Compose is working. ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To view the private page from your workstation, create an SSH tunnel there with <code>ssh -L 8091:127.0.0.1:8091 YOUR_LINUX_USER@SERVER_IP<\/code>, then open <code>http:\/\/127.0.0.1:8091<\/code> locally.<\/p>\n\n\n\n<aside class=\"vl-element vl-cta-banner\" aria-label=\"Run Docker Compose on Rocky Linux 9\">\n\t<div class=\"vl-cta-banner__content\">\n\t\t<p class=\"vl-cta-banner__eyebrow\">Rocky Linux VPS<\/p>\n\t\t<h3 class=\"vl-cta-banner__title\">Run Docker Compose on Rocky Linux 9<\/h3>\n\t\t<p class=\"vl-cta-banner__text\">Deploy private Compose applications on a Rocky Linux 9 VPS with root access.<\/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\">No match for argument: docker-ce<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">DNF is not reading Docker&#8217;s repository, or its metadata refresh failed. Confirm the repository and rebuild the cache:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash sudo dnf repolist &#8211;enabled | grep -i docker sudo dnf makecache dnf list &#8211;showduplicates docker-ce ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The list should contain an <code>el9<\/code> package from Docker&#8217;s stable channel. If the first command returns nothing, repeat the repository command in Step 3 and make sure <code>download.docker.com<\/code> resolves from the VPS.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><code>docker: 'compose' is not a docker command<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The plugin is missing, or the Docker packages came from a different repository. Refresh metadata and install the plugin explicitly:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash sudo dnf makecache sudo dnf install -y docker-compose-plugin docker compose version ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Use <code>docker compose<\/code> with a space. The hyphenated <code>docker-compose<\/code> command is the legacy standalone form and is not required for this installation.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">permission denied while trying to connect to the Docker daemon socket<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The current SSH session has not loaded the new group membership, or <code>usermod<\/code> targeted another account. Reconnect, confirm that <code>id -nG<\/code> includes <code>docker<\/code>, and retry. <code>sudo docker compose ...<\/code> is a temporary workaround while you correct the account.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The checker is unhealthy or Nginx returns connection refused<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The web container may still be starting, the image pull may have failed, or another process may already use port 8091. Inspect both services and the host port:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash docker compose ps docker compose logs &#8211;tail=30 web checker ss -ltnp | grep &#8216;:8091&#8217; ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If another service owns the port, change only the host side of the mapping in <code>compose.yaml<\/code>, such as <code>127.0.0.1:8092:80<\/code>, then recreate the project with <code>docker compose up -d<\/code>. If Nginx returns 403 or the logs mention a bind-mount denial, SELinux may be refusing an unlabeled host directory. Confirm that the site volume ends in <code>:ro,Z<\/code>, then recreate the web service:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash docker compose up -d &#8211;force-recreate web docker compose logs &#8211;tail=30 web ~~~<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keep SELinux enforcing; changing the mount label is safer than disabling the security policy.<\/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 the sample on <code>127.0.0.1<\/code>. If a real application will be public, place it behind a TLS reverse proxy and allow only the proxy&#8217;s services in firewalld:<\/li>\n\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">~~~bash sudo firewall-cmd &#8211;permanent &#8211;add-service=http sudo firewall-cmd &#8211;permanent &#8211;add-service=https sudo firewall-cmd &#8211;reload sudo firewall-cmd &#8211;list-services ~~~<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n\n<li>Do not publish the Docker API, databases, or internal service ports. Use Compose&#8217;s private network for service-to-service traffic.<\/li>\n\n\n<li>Limit <code>docker<\/code> group membership to trusted administrators, keep secrets out of Compose files and public repositories, and review image tags before production use.<\/li>\n\n\n<li>Back up persistent application data before using <code>docker compose down -v<\/code>; the <code>-v<\/code> option removes named volumes.<\/li>\n\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">FAQ<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Can I install Docker Compose without Docker Engine?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">You can install the plugin package separately, but Compose needs the Docker CLI and a reachable Engine to start containers. Installing Engine and the plugin together avoids a partial setup and makes <code>docker compose version<\/code> useful immediately.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does this guide use <code>docker compose<\/code> instead of <code>docker-compose<\/code>?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>docker compose<\/code> is the current CLI plugin command, while <code>docker-compose<\/code> is the older standalone executable. The plugin is updated through Docker&#8217;s RPM repository and is the better default for new projects.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Does the sample need a firewalld rule for port 8091?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. Docker binds the sample to <code>127.0.0.1<\/code>, so it is not directly reachable on the VPS&#8217;s public interface. Use the SSH tunnel in the verification section for remote browser access; add public firewall services only when a TLS reverse proxy is ready.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Will these services return after a reboot?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Docker is enabled to start at boot, and both services use <code>restart: unless-stopped<\/code>. They should return after a normal reboot unless you deliberately stopped or removed the project.<\/p>\n\n\n\n<script type=\"application\/ld+json\">{\"@context\": \"https:\/\/schema.org\", \"@type\": \"FAQPage\", \"mainEntity\": [{\"@type\": \"Question\", \"name\": \"Can I install Docker Compose without Docker Engine?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"You can install the plugin package separately, but Compose needs the Docker CLI and a reachable Engine to start containers. Installing Engine and the plugin together avoids a partial setup and makes `docker compose version` useful immediately.\"}}, {\"@type\": \"Question\", \"name\": \"Why does this guide use `docker compose` instead of `docker-compose`?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"`docker compose` is the current CLI plugin command, while `docker-compose` is the older standalone executable. The plugin is updated through Docker's RPM repository and is the better default for new projects.\"}}, {\"@type\": \"Question\", \"name\": \"Does the sample need a firewalld rule for port 8091?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"No. Docker binds the sample to `127.0.0.1`, so it is not directly reachable on the VPS's public interface. Use the SSH tunnel in the verification section for remote browser access; add public firewall services only when a TLS reverse proxy is ready.\"}}, {\"@type\": \"Question\", \"name\": \"Will these services return after a reboot?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"Docker is enabled to start at boot, and both services use `restart: unless-stopped`. They should return after a normal reboot unless you deliberately stopped or removed the project.\"}}]}<\/script>\n\n","protected":false},"excerpt":{"rendered":"Install Docker Compose on Rocky Linux 9 with Docker Engine, the official CLI plugin, a private test stack, verification commands, and fixes for common errors.","protected":false},"author":1,"featured_media":1078,"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":[119,118,120,100],"class_list":["post-1077","post","type-post","status-publish","format-standard","has-post-thumbnail","category-guides","category-vps-hosting","tag-containers","tag-docker","tag-docker-compose","tag-rocky-linux-9","cs-entry"],"_links":{"self":[{"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/posts\/1077","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=1077"}],"version-history":[{"count":1,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/posts\/1077\/revisions"}],"predecessor-version":[{"id":4020,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/posts\/1077\/revisions\/4020"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/media\/1078"}],"wp:attachment":[{"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/media?parent=1077"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/categories?post=1077"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/tags?post=1077"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}