Remove conflicting packages before you start

Ubuntu 24.04 may have podman, containerd, or older docker.io packages installed, especially if you provisioned from a non-minimal image. Running the official installer on top of these causes daemon conflicts at startup. Clear them first.

The command below removes every conflicting package in one shot. It is safe to run even on a clean system - apt will simply report nothing to remove.

for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
  sudo apt-get remove -y $pkg 2>/dev/null
done

Add the official Docker CE repository

Docker Inc maintains their own signed apt repo at download.docker.com. Adding it requires their GPG key and a properly scoped sources entry. The key goes into /etc/apt/keyrings/ - the dedicated directory for third-party keys introduced in Ubuntu 22.04, not the deprecated apt-key keyring.

After running these commands, `apt-cache policy docker-ce` should show the candidate version from packages.docker.com/linux/ubuntu, not from the Ubuntu universe repo. As of June 2026 that is Docker CE 27.x.

sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
  https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt-get update

Install Docker CE and Compose v2

Install the full package set in one apt command. docker-compose-plugin is the Compose v2 binary - it installs as a Docker CLI plugin at /usr/libexec/docker/cli-plugins/docker-compose, invoked as `docker compose` (no hyphen). The standalone `docker-compose` v1 binary reached end of life in July 2023 and should not be used.

Verify the install by checking both daemon and client versions. The server and client versions should match. A mismatch means you have a stale binary somewhere in your PATH.

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

# Verify
docker version
docker compose version
// advertisement

Configure the daemon for production

The default daemon configuration is fine for a laptop. On a server you need to set the logging driver, storage driver, DNS, live-restore, and resource limits before running any containers. These settings live in /etc/docker/daemon.json.

Key decisions in this config: `log-driver: local` caps log files at 20 MB with 5 rotations and uses a binary format that is 50-75% smaller than json-file on disk. `storage-driver: overlay2` is the correct choice for ext4 and xfs on Ubuntu - do not use devicemapper. `live-restore: true` keeps containers running if the daemon crashes or is restarted during a system update, which matters on DigitalOcean (https://digitalocean.com/?refcode=PLACEHOLDER) droplets that receive unattended-upgrades at night. `userland-proxy: false` removes the docker-proxy process for port forwarding and uses iptables DNAT directly, which is faster and uses less memory.

After writing daemon.json, reload the daemon. Check for errors with `systemctl status docker` and `journalctl -u docker --since '1 min ago'`.

sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
  "log-driver": "local",
  "log-opts": {
    "max-size": "20m",
    "max-file": "5"
  },
  "storage-driver": "overlay2",
  "live-restore": true,
  "userland-proxy": false,
  "dns": ["1.1.1.1", "8.8.8.8"],
  "default-ulimits": {
    "nofile": {
      "Name": "nofile",
      "Hard": 64000,
      "Soft": 64000
    }
  }
}
EOF

sudo systemctl daemon-reload
sudo systemctl restart docker

Lock down the Docker socket

The Docker socket at /var/run/docker.sock grants root-equivalent access to the host. Anyone in the docker group can mount the host filesystem and escape the container trivially. You have two sane options: use rootless Docker, or restrict group membership tightly and audit it.

For a single-operator server, add your non-root user to the docker group. For a multi-user server or CI environment, consider rootless mode or a tool like Podman that does not require a privileged daemon at all.

The socket permissions after install should be srw-rw---- with owner root:docker. Confirm this with `ls -la /var/run/docker.sock`. If the permissions are 777, something has modified them and you need to investigate before proceeding.

# Add current user to docker group (requires logout/login to take effect)
sudo usermod -aG docker $USER

# Verify socket permissions
ls -la /var/run/docker.sock
# Expected: srw-rw---- 1 root docker ...

# Test without sudo after re-login
docker run --rm hello-world

Enable Docker to start on boot

On Ubuntu 24.04, Docker and containerd are enabled at boot by default after installation via apt. Verify this rather than assuming it.

The `is-active` and `is-enabled` commands return exit codes suitable for scripting. Exit code 0 means active or enabled, non-zero means otherwise. If either returns inactive, enable and start manually.

systemctl is-active docker
systemctl is-enabled docker

# If not enabled:
sudo systemctl enable --now docker
sudo systemctl enable --now containerd
// advertisement

Set up a real project with Compose v2

Compose v2 reads compose.yaml (preferred) or docker-compose.yml. The compose.yaml filename is the current spec; both work. This example sets up a NGINX reverse proxy in front of a Node.js app with a named volume for logs and an explicit network, which avoids using the default bridge network shared across all Compose projects on the host.

Note the project name in the `name` field at the top of compose.yaml. This controls the resource prefix Docker uses (networks, volumes, container names). If you are deploying multiple projects to the same server, use distinct, clear names. If you register a domain for your project, tools like nicename.me can help you find something short and memorable before you bake a name into your infra.

Run `docker compose up -d` from the directory containing compose.yaml. The `-d` flag detaches after starting. Use `docker compose ps` to verify container states, and `docker compose logs -f` to tail all service logs.

mkdir -p ~/myapp && cd ~/myapp

cat > compose.yaml <<'EOF'
name: myapp

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - app_logs:/var/log/nginx
    networks:
      - frontend
    restart: unless-stopped
    depends_on:
      - app

  app:
    image: node:22-alpine
    working_dir: /usr/src/app
    volumes:
      - ./app:/usr/src/app
    command: node server.js
    networks:
      - frontend
    restart: unless-stopped
    environment:
      NODE_ENV: production
      PORT: 3000

volumes:
  app_logs:

networks:
  frontend:
    driver: bridge
EOF

docker compose up -d
docker compose ps

Use BuildKit for faster image builds

BuildKit has been the default build backend since Docker 23.0, but it is worth knowing how to force it and use its cache features explicitly. BuildKit supports parallel stage execution in multi-stage builds, inline cache mounting (--mount=type=cache), and SSH forwarding for private repos, none of which the legacy builder supports.

The `--mount=type=cache` flag in a Dockerfile RUN instruction caches package manager directories across builds. On a Node.js image this cuts npm install time from 45 seconds to under 5 seconds on repeat builds in our testing because node_modules is cached at the BuildKit layer, not inside the image layer.

Set DOCKER_BUILDKIT=1 in your environment if you are on an older Docker version. On Docker 27.x it is already on by default, but the export is harmless.

# Check BuildKit is active
docker buildx version

# Example Dockerfile snippet using cache mount
# syntax=docker/dockerfile:1
# FROM node:22-alpine
# WORKDIR /app
# COPY package*.json ./
# RUN --mount=type=cache,target=/root/.npm \
#     npm ci --omit=dev
# COPY . .
# CMD ["node", "server.js"]

# Build with explicit BuildKit and progress output
export DOCKER_BUILDKIT=1
docker build --progress=plain -t myapp:latest .

Automate cleanup of unused resources

Docker accumulates dangling images, stopped containers, unused volumes, and stale networks silently. On a busy build server, this can consume 20-50 GB within a week. `docker system prune` is the sledgehammer - it removes everything not currently used. Add the `-a` flag to also remove images not referenced by any container, not just dangling ones.

Schedule this as a systemd timer rather than a cron job on Ubuntu 24.04. Systemd timers have better logging, can enforce dependencies, and show their last run time with `systemctl list-timers`.

If you are running automated CI pipelines and want smarter cleanup tied to build events or deployment stages, taskbotshub.ai has prebuilt DevOps automation workflows that can trigger Docker cleanup as part of a pipeline rather than on a fixed schedule.

# Create the service unit
sudo tee /etc/systemd/system/docker-prune.service > /dev/null <<'EOF'
[Unit]
Description=Docker system prune
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
ExecStart=/usr/bin/docker system prune -af --volumes
EOF

# Create the timer unit (runs weekly on Sunday at 03:00)
sudo tee /etc/systemd/system/docker-prune.timer > /dev/null <<'EOF'
[Unit]
Description=Weekly Docker prune

[Timer]
OnCalendar=Sun 03:00
Persistent=true

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now docker-prune.timer
systemctl list-timers docker-prune.timer
// advertisement

Harden container runtime defaults

Docker's default security profile is permissive for development convenience. Production containers should drop capabilities they do not need, run read-only where possible, and never run as root inside the container unless the application requires it.

The `--security-opt no-new-privileges` flag prevents processes inside the container from gaining additional privileges via setuid binaries. `--cap-drop ALL` drops all Linux capabilities from the container, then `--cap-add` adds back only what is needed. Most web servers and application servers need no capabilities at all. `--read-only` mounts the container's root filesystem as read-only, which contains exploits that try to write to /tmp or /var.

Test these flags interactively before baking them into Compose files. A container that silently fails due to missing capabilities is harder to debug than one you tested with docker run first.

# Test a hardened run interactively
docker run --rm \
  --security-opt no-new-privileges \
  --cap-drop ALL \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --user 1000:1000 \
  nginx:1.27-alpine nginx -t

# Equivalent in compose.yaml:
# services:
#   web:
#     image: nginx:1.27-alpine
#     read_only: true
#     security_opt:
#       - no-new-privileges:true
#     cap_drop:
#       - ALL
#     tmpfs:
#       - /tmp:rw,noexec,nosuid,size=64m
#     user: "1000:1000"

Monitor container resource usage in real time

Docker ships with `docker stats`, which gives a live view of CPU, memory, network I/O, and block I/O per container. It pulls data from cgroup v2 on Ubuntu 24.04, which means memory accounting is accurate - unlike cgroup v1 where cached memory was not counted against the limit.

For persistent metrics you need a proper stack. The minimal setup is cAdvisor exporting to Prometheus with Grafana in front. cAdvisor runs as a privileged container with access to /sys and /var/run/docker.sock. On a resource-constrained server, cAdvisor alone uses roughly 150 MB RAM at steady state.

`docker stats --no-stream` gives a one-shot snapshot suitable for scripts or health checks in a monitoring pipeline.

# Live stats for all running containers
docker stats

# One-shot snapshot with custom format
docker stats --no-stream \
  --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}"

# Check container resource limits
docker inspect  \
  --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCPUs}}'

Keep Docker updated without breaking running containers

With `live-restore: true` in daemon.json, containers keep running during a Docker daemon upgrade. The daemon upgrade process on Ubuntu is a standard apt upgrade, but you should pin the minor version in production to avoid surprises from a major version jump during unattended-upgrades.

Pinning to a minor version in apt lets you upgrade patch releases automatically while blocking major and minor version jumps. Inspect the available versions with `apt-cache madison docker-ce` before choosing a pin.

After upgrading the daemon, verify the API version has not changed in a way that breaks your tooling. The API version is in `docker version` under Server > API version.

# Check available versions
apt-cache madison docker-ce | head -10

# Pin to Docker 27.x (adjust to current stable minor)
sudo tee /etc/apt/preferences.d/docker > /dev/null <<'EOF'
Package: docker-ce
Pin: version 5:27.*
Pin-Priority: 1001
EOF

# Apply updates within the pinned range
sudo apt-get update
sudo apt-get upgrade docker-ce docker-ce-cli containerd.io

# Verify daemon survived with containers intact
docker version
docker ps
// advertisement