{"id":1007,"date":"2026-11-05T03:00:00","date_gmt":"2026-11-05T03:00:00","guid":{"rendered":"https:\/\/vpslake.com\/blog\/?p=1007"},"modified":"2026-11-05T03:00:00","modified_gmt":"2026-11-05T03:00:00","slug":"install-docker-compose-ubuntu-26-04","status":"publish","type":"post","link":"https:\/\/vpslake.com\/blog\/2026\/11\/05\/install-docker-compose-ubuntu-26-04\/","title":{"rendered":"How to Install Docker Compose on Ubuntu 26.04"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Install Docker Compose on Ubuntu 26.04 by configuring Docker&#8217;s official repository, adding the Compose v2 plugin, and running a small reverse-proxy stack. In about ten minutes, you will have a repeatable <code>compose.yaml<\/code> project whose test service is reachable only from the VPS.<\/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 Ubuntu 26.04 LTS (Resolute Raccoon) 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 account with working <code>sudo<\/code> privileges.<\/li>\n\n\n<li>Outbound HTTPS access to Docker&#8217;s APT repository and Docker Hub.<\/li>\n\n\n<li>A <a href=\"https:\/\/vpslake.com\/buy-vps\">VPSLake Linux VPS<\/a> if you need an Ubuntu server with root access.<\/li>\n\n\n<li>No domain is needed for this local test. Use a domain later if you put a public service behind a TLS reverse proxy.<\/li>\n\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The commands assume a sudo-enabled SSH user. If you are logged in as <code>root<\/code>, remove <code>sudo<\/code>. The <code>docker<\/code> group grants root-equivalent control, so add only trusted administrator accounts.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 1: Confirm Resolute and refresh APT<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Docker publishes packages for specific Ubuntu releases, so check the codename before adding a repository.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>grep -E '^(PRETTY_NAME|VERSION_CODENAME)=' \/etc\/os-release\ndpkg --print-architecture\nsudo apt update<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The release check should report Ubuntu 26.04 and <code>resolute<\/code>, with an architecture such as <code>amd64<\/code> or <code>arm64<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>PRETTY_NAME=\"Ubuntu 26.04.1 LTS\"\nVERSION_CODENAME=resolute\namd64<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If <code>apt update<\/code> reports a DNS, mirror, or signature error, fix that before continuing. Docker&#8217;s packages cannot be installed reliably while the base APT configuration is unhealthy.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 2: Add Docker&#8217;s signed Ubuntu repository<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The keyring and deb822 source file let APT verify Docker packages without the deprecated <code>apt-key<\/code> workflow. The command reads Ubuntu&#8217;s codename from the machine, so it cannot accidentally use a Noble or Jammy source on Resolute.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt install -y ca-certificates curl\nsudo install -m 0755 -d \/etc\/apt\/keyrings\nsudo curl -fsSL https:\/\/download.docker.com\/linux\/ubuntu\/gpg -o \/etc\/apt\/keyrings\/docker.asc\nsudo chmod a+r \/etc\/apt\/keyrings\/docker.asc\nsudo tee \/etc\/apt\/sources.list.d\/docker.sources &gt;\/dev\/null &lt;&lt;EOF\nTypes: deb\nURIs: https:\/\/download.docker.com\/linux\/ubuntu\nSuites: $(. \/etc\/os-release &amp;&amp; echo \"${UBUNTU_CODENAME:-$VERSION_CODENAME}\")\nComponents: stable\nArchitectures: $(dpkg --print-architecture)\nSigned-By: \/etc\/apt\/keyrings\/docker.asc\nEOF\nsudo apt update<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Confirm that APT sees an Engine candidate from Docker&#8217;s repository:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>apt-cache policy docker-ce<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The output should show a <code>Candidate<\/code> version and a source containing <code>download.docker.com<\/code> and <code>resolute<\/code>. Docker&#8217;s <a href=\"https:\/\/docs.docker.com\/engine\/install\/ubuntu\/\" target=\"_blank\" rel=\"noopener\">Ubuntu Engine installation guide<\/a> lists Resolute as supported and documents this repository method.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 3: Install Docker Engine and Compose v2<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Compose v2 is a Docker CLI plugin. Installing it with the Engine gives you the maintained <code>docker compose<\/code> command instead of the legacy <code>docker-compose<\/code> executable.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\nsudo systemctl enable --now docker\nsudo systemctl is-active 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\">You should see an active daemon, Docker client and server versions, and a Compose v2 version. Exact patch numbers change as packages are updated:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>active\nClient 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 smoke test before creating a project:<\/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 output includes a confirmation similar to this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Hello from Docker!\nYour installation appears to be working correctly.<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If this VPS already has <code>docker.io<\/code>, <code>docker-compose-v2<\/code>, or a standalone <code>docker-compose<\/code> installation, identify existing workloads before replacing packages. Removing a package does not automatically remove <code>\/var\/lib\/docker<\/code>, but an unplanned package migration can still interrupt services.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 4: Allow your SSH user to run Docker<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Docker&#8217;s Unix socket is root-owned by default. The <code>docker<\/code> group removes repeated <code>sudo<\/code> prompts, but anyone in it can effectively administer the host through Docker.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo groupadd --force docker\nsudo usermod -aG docker \"$USER\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Disconnect and open a new SSH session so the group membership is loaded, then test access:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>id -nG\ndocker info --format 'Server {{.ServerVersion}}'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The first command should include <code>docker<\/code>, and the second should print the Engine version. Until reconnecting, use <code>sudo docker compose ...<\/code> or run <code>newgrp docker<\/code> in the current shell. Rootless Docker is a better choice when this administrative boundary is too broad.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 5: Build a private two-service Compose project<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The example uses Caddy as a reverse proxy in front of a small HTTP application. Both services share Compose&#8217;s private network, while Caddy is published only on the VPS loopback interface; this demonstrates service discovery without exposing a test app to the internet.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Create the project directory:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>mkdir -p ~\/compose-resolute\ncd ~\/compose-resolute<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Create a Caddyfile. The name <code>app<\/code> is the Compose service name, not an IP address; Compose resolves it on the project network.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>tee Caddyfile &gt;\/dev\/null &lt;&lt;'EOF'\n:80 {\n    reverse_proxy app:80\n}\nEOF<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Now create <code>compose.yaml<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>services:\n  proxy:\n    image: caddy:2-alpine\n    ports:\n      - \"127.0.0.1:8092:80\"\n    volumes:\n      - .\/Caddyfile:\/etc\/caddy\/Caddyfile:ro\n      - caddy_data:\/data\n      - caddy_config:\/config\n    depends_on:\n      - app\n\n  app:\n    image: traefik\/whoami\n    expose:\n      - \"80\"\n\nvolumes:\n  caddy_data:\n  caddy_config:<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The read-only Caddyfile mount protects the host configuration. The <code>expose<\/code> entry documents the app&#8217;s internal port but does not publish it; only <code>proxy<\/code> has a host mapping, and <code>127.0.0.1<\/code> means remote clients cannot connect directly. Caddy&#8217;s <a href=\"https:\/\/caddyserver.com\/docs\/caddyfile\/directives\/reverse_proxy\" target=\"_blank\" rel=\"noopener\">reverse proxy documentation<\/a> explains the directive used here.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 6: Validate and start the services<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Render the file first so YAML and service-name mistakes fail before images are downloaded.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>docker compose config --quiet\ndocker compose pull\ndocker compose up -d\ndocker compose ps<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>config --quiet<\/code> should return no output and exit successfully. After the first pull, both services should show <code>Up<\/code> or <code>running<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>NAME                    SERVICE   STATUS       PORTS\ncompose-resolute-proxy-1 proxy     Up           127.0.0.1:8092-&gt;80\/tcp\ncompose-resolute-app-1   app       Up<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Container names follow the project directory and may differ if you choose another path. Use the <code>SERVICE<\/code> column, such as <code>proxy<\/code> or <code>app<\/code>, in later commands. Docker&#8217;s <a href=\"https:\/\/docs.docker.com\/reference\/compose-file\/\" target=\"_blank\" rel=\"noopener\">Compose file reference<\/a> covers the service, volume, network, and port keys used here.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Verify Docker Compose on Ubuntu 26.04<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Query the proxy locally and validate Caddy&#8217;s loaded configuration. The response proves that the request crossed the Compose network from Caddy to the app container.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl --fail --silent --show-error http:\/\/127.0.0.1:8092 | grep -E '^(Hostname|IP:)'\ndocker compose exec proxy caddy validate --config \/etc\/caddy\/Caddyfile --adapter caddyfile\ndocker compose logs --tail 20 proxy app<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Expected output includes an application hostname, one or more container IP addresses, and a successful Caddy validation message:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Hostname: 4f2c9b7d1a20\nIP: 127.0.0.1\nValid configuration<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The hostname and IP values are generated at runtime, so your output will differ. Port 8092 is bound to loopback and does not need a UFW allow rule. If you later publish a service on 80 or 443, allow only those ports, add TLS, and account for Docker&#8217;s published-port behavior; VPSLake&#8217;s <a href=\"https:\/\/vpslake.com\/ufw-firewall-generator\">UFW firewall generator<\/a> can help draft the host rules.<\/p>\n\n\n\n<aside class=\"vl-element vl-cta-banner\" aria-label=\"Run Compose on Resolute\">\n\t<div class=\"vl-cta-banner__content\">\n\t\t<p class=\"vl-cta-banner__eyebrow\">Ubuntu 26.04 VPS<\/p>\n\t\t<h3 class=\"vl-cta-banner__title\">Run Compose on Resolute<\/h3>\n\t\t<p class=\"vl-cta-banner__text\">Launch private Docker Compose services on an Ubuntu 26.04 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\"><code>docker: 'compose' is not a docker command<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The CLI plugin is missing, or the shell is using a different Docker installation. Refresh the package index and install the plugin from the configured Docker repository:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt update\nsudo apt install -y docker-compose-plugin\ndocker compose version<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Use a space in <code>docker compose<\/code>. The hyphenated command is the legacy standalone interface and is not required here.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><code>E: Package 'docker-ce' has no installation candidate<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">APT is not reading the Resolute repository, commonly because the source file was edited with the wrong suite or <code>apt update<\/code> failed. Inspect and refresh it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>cat \/etc\/apt\/sources.list.d\/docker.sources\nsudo apt update\napt-cache policy docker-ce<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The file should contain <code>Suites: resolute<\/code>, and the update must finish without errors. Do not change the suite to <code>noble<\/code> just to obtain a package; mixing Ubuntu releases can replace core dependencies.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><code>permission denied while trying to connect to the Docker daemon socket<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The current SSH session has not loaded the new group membership, or the command is running as another account. Reconnect, run <code>id -nG<\/code>, and retry. <code>sudo docker compose ...<\/code> is a temporary workaround, but remember that <code>docker<\/code> group membership is administrative access.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><code>502 Bad Gateway<\/code> or <code>connection refused<\/code> on port 8092<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The proxy may have started before the app was ready, the app container may have exited, or the Caddyfile may point to <code>localhost<\/code>. Inside the proxy container, <code>localhost<\/code> means the proxy itself; the upstream must be <code>app:80<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>docker compose ps\ndocker compose logs proxy app\ndocker compose up -d<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If port 8092 is already occupied, change the left side of the mapping to an unused local port, such as <code>127.0.0.1:8093:80<\/code>, and run <code>docker compose up -d<\/code> again.<\/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 Docker&#8217;s API on its Unix socket. Never expose an unauthenticated daemon on port 2375 or 2376.<\/li>\n\n\n<li>Put a real public application behind HTTPS and authentication before changing the binding from <code>127.0.0.1<\/code> to a public interface.<\/li>\n\n\n<li>Review image tags and pin production images to approved tags or digests instead of treating <code>latest<\/code> as a release process.<\/li>\n\n\n<li>Remove the example cleanly with <code>docker compose down<\/code>; use <code>docker compose down -v<\/code> only when you intentionally want to delete the named Caddy 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\">Is Docker Compose included in Docker Engine on Ubuntu 26.04?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Engine and Compose are separate packages in Docker&#8217;s APT repository. Installing <code>docker-compose-plugin<\/code> adds the <code>docker compose<\/code> subcommand, which you can confirm with <code>docker compose version<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does the example use <code>compose.yaml<\/code> instead of <code>docker-compose.yml<\/code>?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Compose v2 recognizes both filenames, but <code>compose.yaml<\/code> is the current default for a new project. If a project uses another filename, pass it explicitly with <code>docker compose -f FILE up -d<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I open port 8092 to access the demo from my laptop?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Do not publish the demo directly just for convenience. Use an SSH tunnel such as <code>ssh -L 8092:127.0.0.1:8092 USER@SERVER_IP<\/code>, then browse to <code>http:\/\/127.0.0.1:8092<\/code> on your laptop; this preserves the loopback-only server binding.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Does <code>docker compose down<\/code> erase the Caddy volumes?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. <code>down<\/code> removes the containers and network but keeps named volumes by default. Add <code>-v<\/code> only for this disposable example or after confirming that the volumes contain no certificates or other data you need.<\/p>\n\n\n\n<script type=\"application\/ld+json\">{\"@context\": \"https:\/\/schema.org\", \"@type\": \"FAQPage\", \"mainEntity\": [{\"@type\": \"Question\", \"name\": \"Is Docker Compose included in Docker Engine on Ubuntu 26.04?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"The Engine and Compose are separate packages in Docker's APT repository. Installing `docker-compose-plugin` adds the `docker compose` subcommand, which you can confirm with `docker compose version`.\"}}, {\"@type\": \"Question\", \"name\": \"Why does the example use `compose.yaml` instead of `docker-compose.yml`?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"Compose v2 recognizes both filenames, but `compose.yaml` is the current default for a new project. If a project uses another filename, pass it explicitly with `docker compose -f FILE up -d`.\"}}, {\"@type\": \"Question\", \"name\": \"Can I open port 8092 to access the demo from my laptop?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"Do not publish the demo directly just for convenience. Use an SSH tunnel such as `ssh -L 8092:127.0.0.1:8092 USER@SERVER_IP`, then browse to `http:\/\/127.0.0.1:8092` on your laptop; this preserves the loopback-only server binding.\"}}, {\"@type\": \"Question\", \"name\": \"Does `docker compose down` erase the Caddy volumes?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"No. `down` removes the containers and network but keeps named volumes by default. Add `-v` only for this disposable example or after confirming that the volumes contain no certificates or other data you need.\"}}]}<\/script>\n\n","protected":false},"excerpt":{"rendered":"Install Docker Compose on Ubuntu 26.04 with Docker&#8217;s signed APT repository, run a private two-service stack, verify it, and fix setup errors fast.","protected":false},"author":1,"featured_media":1008,"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,104],"class_list":["post-1007","post","type-post","status-publish","format-standard","has-post-thumbnail","category-guides","category-vps-hosting","tag-containers","tag-docker","tag-docker-compose","tag-ubuntu-26-04","cs-entry"],"_links":{"self":[{"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/posts\/1007","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=1007"}],"version-history":[{"count":1,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/posts\/1007\/revisions"}],"predecessor-version":[{"id":3894,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/posts\/1007\/revisions\/3894"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/media\/1008"}],"wp:attachment":[{"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/media?parent=1007"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/categories?post=1007"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/vpslake.com\/blog\/wp-json\/wp\/v2\/tags?post=1007"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}