Docker

Install Docker and Docker Compose on Ubuntu 24.04 / Debian 13

UFW doesn’t protect a port that Docker publishes. On our Ubuntu 24.04 test server, with UFW active and set to deny all incoming traffic, a WordPress container published on port 8080 still answered requests from another machine on the LAN. Docker writes its own NAT rules ahead of UFW’s chains, so the firewall never sees that traffic. If you install Docker and Docker Compose on an internet-facing server, know that first; the fix is one line.

Original content from computingforgeeks.com - post 29951

The steps install Docker CE and the Compose plugin from Docker’s official repository on Ubuntu 24.04 and Debian 13, then build a real WordPress, MariaDB and Redis stack with health checks, an isolated database network and log rotation. We ran everything on Ubuntu 24.04.4 LTS and Debian 13 (trixie) in October 2026 with Docker Engine 29.9.1 and Compose 5.6.0. On Ubuntu 26.04, follow the dedicated Docker CE on Ubuntu 26.04 guide instead.

What you need

Docker itself needs very little, so you size the server for the containers you’ll run on it. The stack in this guide took 1.7 GB of images and 245 MB of volumes on a fresh install, and MariaDB alone sat at 123 MB of RAM with an empty database. Before you start, you need:

  • Ubuntu 24.04 LTS or Debian 13, 64-bit
  • a user with sudo rights
  • outbound HTTPS to download.docker.com and Docker Hub
  • 5 GB or more free under /var/lib/docker for the example stack

Both lab VMs ran 2 vCPU and 4 GB of RAM, which is a floor for following along, not a production spec. A real WordPress site’s database and uploads decide the disk, and the PHP worker count decides the RAM. If Docker is new to you and you want the concepts behind images, volumes and networks before you run production on it, the Docker books we recommend cover them in the order you will need them.

Remove the distro Docker packages

Ubuntu and Debian both ship their own docker.io build (Debian-only readers can also follow the shorter Docker CE on Debian guide), and it conflicts with Docker’s packages because both provide containerd and runc. The archive version also lags. On the Ubuntu 24.04 VM, the archive and Docker’s repo offered these builds the same day:

ComponentUbuntu archive (docker.io)Docker repo (docker-ce)
Engine29.1.329.9.1
Compose2.40.3 (docker-compose-v2)5.6.0 (docker-compose-plugin)
containerd2.2.12.4.2
runc1.3.41.5.2

A full Compose major version behind is enough reason to switch. Remove whatever is installed; the dpkg --get-selections filter means the command only touches packages that exist, so it is safe on a clean server too:

sudo apt remove -y $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc 2>/dev/null | cut -f1)

On the Ubuntu box that had docker.io installed, apt removed four packages and freed 344 MB. Note the warning about docker.socket: the old socket unit is still running when its service goes away, and that leftover breaks the new install later if you don’t deal with it:

The following packages will be REMOVED:
  containerd docker-compose-v2 docker.io runc
0 upgraded, 0 newly installed, 4 to remove and 218 not upgraded.
After this operation, 344 MB disk space will be freed.
Removing docker-compose-v2 (2.40.3+ds1-0ubuntu1~24.04.1) ...
Removing docker.io (29.1.3-0ubuntu3~24.04.2) ...
'/usr/share/docker.io/contrib/nuke-graph-directory.sh' -> '/var/lib/docker/nuke-graph-directory.sh'
Stopping 'docker.service', but its triggering units are still active:
docker.socket
Removing containerd (2.2.1-0ubuntu1~24.04.3) ...
Removing runc (1.3.4-0ubuntu1~24.04.1) ...

Removing the packages doesn’t delete /var/lib/docker, so volumes and image data from the old install stay on disk. We had no containers on that VM to carry over, so we can’t promise the new engine picks up every old container. Back up any volume you care about before you switch, with a plain tar of its directory if nothing else.

Add Docker’s apt repository

The commands are the same on both distros except for the repo path, so set it once. Use ubuntu on Ubuntu 24.04 and debian on Debian 13. Every later command reads this variable, so export it again if you reconnect before finishing:

export DISTRO=ubuntu

Download Docker’s signing key into a dedicated keyring, so apt only trusts it for Docker’s repo and not for every source on the system. The Signed-By line in the source file below points at this exact path, so keep the filename as shown:

sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL "https://download.docker.com/linux/${DISTRO}/gpg" -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

Write the repository as a deb822 .sources file, the format Docker’s docs now use. The suite is read from /etc/os-release, which gave noble on Ubuntu 24.04 and trixie on Debian 13, so the same line works on both:

printf 'Types: deb\nURIs: https://download.docker.com/linux/%s\nSuites: %s\nComponents: stable\nArchitectures: %s\nSigned-By: /etc/apt/keyrings/docker.asc\n' "${DISTRO}" "$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")" "$(dpkg --print-architecture)" | sudo tee /etc/apt/sources.list.d/docker.sources

On Debian 13 the file came out like this. On Debian testing the codename is forky, and Docker’s repo has no forky suite yet (its Release file returned 404 when we checked), so this guide only works on stable Debian:

Types: deb
URIs: https://download.docker.com/linux/debian
Suites: trixie
Components: stable
Architectures: amd64
Signed-By: /etc/apt/keyrings/docker.asc

If you manage Rocky Linux or Fedora hosts as well, the repo setup differs, and our multi-distro Docker install guide covers those package managers side by side.

Install Docker Engine and the Compose plugin

Refresh apt and install the engine, CLI, containerd, Buildx and Compose together. Compose v2 and later ship as a Docker CLI plugin, so you run it as docker compose; there is no separate docker-compose binary to download:

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

The two distros differ in what comes along. Ubuntu 24.04 installed six packages, 101 MB to download and 388 MB on disk. Debian 13 installed 22, 121 MB and 495 MB, because apt there also pulled Git and Perl from docker-ce‘s recommended packages. That is harmless, but add --no-install-recommends on a lean server. Both enable and start the service during install, and the hello-world container confirms the engine works end to end:

sudo docker run --rm hello-world
sudo docker compose version

You should see the greeting followed by the plugin version. If the greeting appears but the version line doesn’t, the Compose plugin package is missing, so rerun the install line before you go further:

Hello from Docker!
This message shows that your installation appears to be working correctly.
...
Docker Compose version v5.6.0

Run docker compose with a space from here on. Scripts that still call docker-compose with a hyphen fail on a plugin-only install, so update them as you move them to this server.

Error: “failed to load listeners: no sockets found via socket activation”

On the Ubuntu VM where we replaced docker.io, the new engine refused to start. hello-world printed “Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?”, and the journal showed the real cause:

sudo journalctl -u docker --no-pager -n 60

dockerd started, looked for the socket systemd should have handed it, found none and quit. It went through that cycle three times in five seconds before systemd stopped retrying. These are the journal lines from one attempt and the final one:

Oct 17 22:54:47 docker04 dockerd[8241]: failed to load listeners: no sockets found via socket activation: make sure the service was started by systemd
Oct 17 22:54:47 docker04 systemd[1]: docker.service: Main process exited, code=exited, status=1/FAILURE
Oct 17 22:54:47 docker04 systemd[1]: docker.service: Failed with result 'exit-code'.
Oct 17 22:54:47 docker04 systemd[1]: Failed to start docker.service - Docker Application Container Engine.
Oct 17 22:54:49 docker04 systemd[1]: docker.service: Failed with result 'exit-code'.
Oct 17 22:54:49 docker04 systemd[1]: Failed to start docker.service - Docker Application Container Engine.

The old docker.socket stayed up while docker.io was removed, and had failed by the time the new package started the service, so systemd had no socket to hand to the new daemon. systemctl is-active docker.socket printed failed on our VM. Reload the units and restart the socket before the service:

sudo systemctl daemon-reload
sudo systemctl restart docker.socket
sudo systemctl restart docker

Both units then reported active and hello-world ran. We didn’t need it, but if systemd answers a restart with “Start request repeated too quickly”, clear the start limit with sudo systemctl reset-failed docker docker.socket and run the three commands again. A clean server never hits this; it only shows up when docker.io was installed before. If you are migrating several hosts from docker.io, put the socket restart in your script straight after the install so none of them stalls on this.

Run Docker as your user and rotate logs

Typing sudo for every Compose command gets old, so add your account to the docker group. That group is root-equivalent, because its members can start a container that mounts the host’s root filesystem, so only add people you would give sudo to anyway:

sudo usermod -aG docker $USER
newgrp docker

While you’re in system config, fix the log default too. Docker’s json-file driver doesn’t rotate by default, so a noisy container eventually fills the disk. The local driver compresses and rotates, and these limits keep at most three 10 MB files per container:

printf '{\n  "log-driver": "local",\n  "log-opts": {\n    "max-size": "10m",\n    "max-file": "3"\n  }\n}\n' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker

Confirm the daemon picked it up. Containers created from now on use it, while existing ones keep their old driver until they are recreated. Recreate an existing stack with docker compose up -d --force-recreate when you want it on the new driver:

docker info --format '{{.LoggingDriver}}'

It should print local. If the daemon fails to restart instead, the JSON has a typo, so run python3 -m json.tool /etc/docker/daemon.json to find the line before you try again.

Write a Docker Compose stack

A real stack shows what Compose is for better than a single nginx container, because it has three services that start in order, talk over private networks and keep data in volumes. This one runs WordPress on Apache with MariaDB and a Redis container ready for an object-cache plugin. Create a project directory. Compose uses its name as the prefix for every container, network and volume, which is why the names below start with stack:

mkdir -p ~/stack && cd ~/stack

Secrets go in a .env file next to the Compose file rather than in the YAML, so you can commit the YAML without leaking passwords. Open it:

vim ~/stack/.env

Add the database credentials, changing both passwords. Avoid # in values, because Compose can read it as the start of a comment:

MYSQL_ROOT_PASSWORD=Db_Root_Pass_2026
MYSQL_DATABASE=wordpress
MYSQL_USER=wpuser
MYSQL_PASSWORD=Wp_User_Pass_2026
WORDPRESS_DB_HOST=mariadb:3306
WORDPRESS_DB_USER=wpuser
WORDPRESS_DB_PASSWORD=Wp_User_Pass_2026
WORDPRESS_DB_NAME=wordpress

Lock the file down to your user, since it holds the root database password. Compose runs as you, so it can still read it.

chmod 600 ~/stack/.env

Now the Compose file. Current Compose looks for compose.yaml first and no longer needs a version: key:

vim ~/stack/compose.yaml

Paste the full file. It’s the exact one we ran on both servers, so if you trim it, run the validation step below before starting anything.

services:
  mariadb:
    image: mariadb:11.8
    restart: unless-stopped
    env_file: .env
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - backend
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  redis:
    image: redis:8-alpine
    restart: unless-stopped
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3

  wordpress:
    image: wordpress:6-apache
    restart: unless-stopped
    depends_on:
      mariadb:
        condition: service_healthy
      redis:
        condition: service_healthy
    env_file: .env
    environment:
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_REDIS_HOST', 'redis');
        define('WP_REDIS_PORT', 6379);
    ports:
      - "8080:80"
    volumes:
      - wp_data:/var/www/html
      - ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
    networks:
      - frontend
      - backend
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
    logging:
      driver: local
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  db_data:
  redis_data:
  wp_data:

networks:
  frontend:
  backend:
    internal: true

One trade-off in this file: env_file: .env hands every variable in .env to both containers, so MYSQL_ROOT_PASSWORD is also visible inside the WordPress container, for example in docker inspect output. For production, split it into one env file per service, or use environment: with ${VAR} references per service; we did not test that variant here.

Most of the file is ordinary service definitions, but four settings decide how the stack behaves when something goes wrong, and you should know what each one buys you before you copy it:

  • depends_on with service_healthy: WordPress waits for InnoDB and Redis
  • internal: true on backend: MariaDB and Redis get no route out
  • deploy.resources.limits: WordPress capped at one CPU and 512 MB
  • restart: unless-stopped: the stack returns after a reboot

The health conditions matter most on first boot, because MariaDB takes several seconds to initialise an empty data directory and WordPress would otherwise start against a database that isn’t listening yet. We saw the restart policy work when we powered the Debian VM back on: docker compose ps showed all three containers up a few seconds after boot, with nobody running up.

The file mounts a small PHP override for upload limits, so create that too. The mount path is relative to compose.yaml, so the file must sit in the same directory:

vim ~/stack/uploads.ini

Add these four settings. They raise WordPress’s upload ceiling, which defaults to 2 MB in the official image and is too small for most theme and plugin zip files.

upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 300

Your project directory now holds three files: .env, compose.yaml and uploads.ini. If you want a second example, the Compose on Ubuntu 26.04 guide builds a different stack on the newer LTS.

Start the stack with Docker Compose

Validate the file before you start anything. config --quiet prints nothing on success and names the line on a YAML or schema error, which is quicker than reading a half-started stack’s logs. You get config-ok when it passes.

cd ~/stack
docker compose config --quiet && echo config-ok

Start it detached. --wait makes the command block until every service with a health check reports healthy, so your script can run the next step safely and a failing health check shows up as a non-zero exit.

docker compose up -d --wait

The first start pulls about 1.7 GB of images. A plain up -d with those pulls took 73 seconds on Debian 13, and up -d --wait took 48 seconds on Ubuntu 24.04, where the wait for health checks is included. Check the result and hit the site locally, before you involve a browser or DNS.

docker compose ps
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" localhost:8080/

Both backends show (healthy), and WordPress answers with a 302 to its installer, which is what a fresh site should do. Per-container memory came in well under the limits. WordPress used 36 MB of its 512 MB cap, measured seconds after it started, so expect more once PHP workers warm up and real traffic arrives:

Docker Compose WordPress stack running on Debian 13

Don’t run the installer yet if you plan to put the site behind a proxy on a domain. WordPress stores the address you install it from as its site URL, so installing on :8080 and moving to a domain later leaves redirects pointing at the old address. Do the loopback bind and proxy from the UFW section first, then install through the final URL. The Redis container does nothing for WordPress until you add an object-cache plugin, such as Redis Object Cache, and point it at the redis host that WORDPRESS_CONFIG_EXTRA already defines; we did not test the plugin in this lab.

If MariaDB stays at health: starting past the 30-second start period, read its log with docker compose logs mariadb. Remember that the MariaDB image only applies the .env passwords when it initialises an empty data directory, so editing them later changes nothing until you recreate the db_data volume. Open http://10.0.1.50:8080 in a browser, using your server’s address, to finish the WordPress installer. Keep a copy of .env with your backups for the same reason, since a restored volume only opens with the password it was created with.

Manage the stack

Every Compose command runs from the project directory and acts on the whole stack unless you name a service. These are the ones you will use daily. Compose finds the project by the directory you’re in, so run these from ~/stack or pass the file with -f:

docker compose logs -f wordpress
docker compose exec redis redis-cli ping
docker compose exec wordpress php -r 'echo ini_get("upload_max_filesize"), PHP_EOL;'
docker compose down
docker compose up -d --wait

The two exec checks confirmed Redis answers and the PHP override is live, returning PONG and 64M. Running down removes the containers and networks but keeps named volumes, so the database survives. After a full down and up cycle, our three volumes were untouched.

DRIVER    VOLUME NAME
local     stack_db_data
local     stack_redis_data
local     stack_wp_data

Adding -v to down deletes those volumes and the site with them, so keep that flag for throwaway test stacks. For a longer tour of Compose subcommands, see our guide to managing containers with Docker Compose. Before you change MariaDB’s image tag, dump the database with mariadb-dump first, so you hold a copy that doesn’t depend on the volume.

Keep the database off the network

The internal: true flag on the backend network does more than tidy up. A container attached only to an internal network has no gateway and no external DNS, so a compromised Redis or MariaDB cannot call out. We checked it from inside each container:

docker compose exec redis wget -q -T 5 -O /dev/null http://deb.debian.org/
docker compose exec wordpress curl -s -o /dev/null -w "%{http_code}\n" --max-time 5 http://deb.debian.org/

Redis couldn’t even resolve the name, while WordPress, which also sits on the frontend network, got a 200. That is the internal network doing its job, with no gateway and no outside DNS for anything attached only to it:

wget: bad address 'deb.debian.org'
200

Service-to-service names still resolve inside the backend network, which is how WordPress finds mariadb:3306. Put any service that only needs to be reached by other containers on an internal network like this, and give outbound access only to the ones that fetch updates or call APIs.

Stop Docker from bypassing UFW

This is the test behind the intro. On Ubuntu 24.04 we allowed only OpenSSH and enabled UFW with its default deny-incoming policy. Allow SSH before you enable UFW on a remote server, or the enable step cuts your own session off; if your SSH daemon listens on another port, allow that port number instead of the OpenSSH profile:

sudo ufw allow OpenSSH
sudo ufw --force enable
sudo ufw status verbose

UFW reported only port 22 open. Any port missing from that table should now be unreachable from outside, and that is exactly what the next test checks:

Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp (OpenSSH)           ALLOW IN    Anywhere
22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)

From a second machine, with 10.0.1.50 standing in for the server’s address, the WordPress port still answered with its 302, so UFW never filtered it. Docker publishes ports through its own iptables NAT and forward rules, which packets hit before UFW’s input chain. The fix that works regardless of firewall is to bind the published port to loopback and put a reverse proxy, which UFW does govern, in front. Change the ports line in compose.yaml:

    ports:
      - "127.0.0.1:8080:80"

Apply it. Compose compares the file with the running containers, so MariaDB and Redis keep running while WordPress restarts with the new binding:

docker compose up -d --wait
docker compose ps wordpress --format "{{.Ports}}"

The port now listens on loopback only. Docker’s NAT rule now matches 127.0.0.1 only, so a packet arriving on the LAN interface has nothing to hit:

127.0.0.1:8080->80/tcp

A local curl still got the 302, while the same request from the second machine now timed out and printed 000. This is the command we ran from that machine, with your server’s address in place of 10.0.1.50:

curl -s -o /dev/null -w "%{http_code}\n" --max-time 5 http://10.0.1.50:8080/

Before the change it printed 302. To publish the site properly from here, you put the firewall back in charge in three steps:

  1. Install Nginx or Caddy on the host and proxy it to 127.0.0.1:8080.
  2. Open 80 and 443 in UFW for the proxy with sudo ufw allow 80,443/tcp, and nothing else.
  3. Repeat the remote curl on 8080 and confirm it times out.

Every other service you add to the stack gets the same treatment. If a port has no business being public, bind it to loopback in the Compose file, because UFW will not stop it for you.

Reclaim disk space

Images pile up quietly, because every pull of a new tag leaves the old one on disk. Start by measuring, since the output tells you whether images, volumes or build cache are the problem:

docker system df

On the Debian server, images were almost all of it, and the RECLAIMABLE column showed only 13 MB that nothing used. On a fresh host pruning barely helps, but on an older one that column is the first number to check:

TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          5         3         1.709GB   13.04MB (0%)
Containers      3         3         81.92kB   0B (0%)
Local Volumes   3         3         244.8MB   0B (0%)
Build Cache     0         0         0B        0B

Remove every image no container uses. Images belonging to running containers are never touched, which is why the stack’s three stayed. A plain docker image prune without -a reclaimed 0 B here, because it only removes dangling layers, not unused tagged images:

docker image prune -a -f

It deleted the leftover alpine and hello-world images and reported Total reclaimed space: 13.06MB. On a long-running host that number is usually gigabytes. We’d run it after each round of image updates rather than on a blind cron schedule, and we would leave docker volume prune out of any automation, because a stopped stack’s volumes look unused and that is your data. If disk keeps growing after a prune, check docker system df -v for the container or volume responsible before deleting anything else.

Keep reading

Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) Ubuntu Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Ubuntu 26.04 vs 24.04 LTS: Should You Upgrade Now? Ubuntu Ubuntu 26.04 vs 24.04 LTS: Should You Upgrade Now? Gatus Monitoring in Docker: Health Checks, Alerts, Status Page Containers Gatus Monitoring in Docker: Health Checks, Alerts, Status Page Raspberry Pi 5 / CM5 k3s Cluster: Turing Pi vs PoE vs DeskPi Containers Raspberry Pi 5 / CM5 k3s Cluster: Turing Pi vs PoE vs DeskPi How To Install Snap on Ubuntu or Debian Containers How To Install Snap on Ubuntu or Debian

2 thoughts on “Install Docker and Docker Compose on Ubuntu 24.04 / Debian 13”

  1. I initially got the following error when I ran “docker version”:
    “Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?”

    So I ran “sudo systemctl start docker”, then ran “docker version”, and I was able to get the correct system output.

    Reply

Leave a Comment

Press ESC to close