Containers

Install Docker Compose on Ubuntu 26.04 LTS

Docker Compose v2 ships as a plugin with Docker CE. No separate binary to download, no Python dependency to manage, no pip install to break. You run docker compose (with a space, not a hyphen) and it just works.

Original content from computingforgeeks.com - post 166057

This guide walks through installing Docker Compose on Ubuntu 26.04 LTS, then builds two real multi-container stacks: WordPress with MariaDB and Redis, and a Flask API behind Nginx with PostgreSQL. Both examples include health checks, environment variables via .env files, volume management, and compose lifecycle commands.

Tested April 2026 | Ubuntu 26.04 LTS (kernel 7.0), Docker CE 29.4.0, Docker Compose v5.1.2

Prerequisites

Before you start, make sure you have a running Ubuntu 26.04 system with sudo or root access. If this is a fresh server, run through the initial server setup for Ubuntu 26.04 first.

  • Ubuntu 26.04 LTS (tested on kernel 7.0.0-10-generic)
  • Root or sudo access
  • Internet connectivity for pulling images
  • Minimum 2 GB RAM, 20 GB disk (for the example stacks)

Ubuntu 26.04 runs cgroup v2 exclusively with systemd 259. Most modern container images work fine, but very old images (Alpine 3.13 and earlier, some legacy CentOS 7 images) may need updates.

Install Docker CE and Docker Compose

Docker Compose v2 is bundled with the docker-compose-plugin package from Docker’s official repository. If you already followed our Docker CE installation guide for Ubuntu 26.04, Compose is already installed. Otherwise, set up the Docker apt repository and install everything in one pass.

Install the prerequisite packages:

sudo apt update
sudo apt install -y ca-certificates curl gnupg

Add Docker’s official GPG key and repository:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

Install Docker CE along with the Compose plugin and Buildx:

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

Verify Docker and Compose are running:

docker --version
docker compose version

You should see both versions confirmed:

Docker version 29.4.0, build 9d7ad9f
Docker Compose version v5.1.2

The Docker daemon should already be active. Confirm with:

sudo systemctl is-active docker

The output reads active if everything is working. Check the cgroup driver to confirm v2:

docker info | grep -E "Cgroup|Storage|Server Version"

Expected output on Ubuntu 26.04:

 Server Version: 29.4.0
 Storage Driver: overlayfs
 Cgroup Driver: systemd
 Cgroup Version: 2

Example 1: WordPress with MariaDB and Redis

A classic three-tier setup: WordPress for the frontend, MariaDB for the database, and Redis for object caching. This example covers environment variables via .env files, health checks, named volumes, and service dependencies.

Create a project directory:

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

Create the .env File

Sensitive values like database credentials belong in a .env file, not hardcoded in the compose file. Compose reads .env automatically from the project directory.

vi .env

Add the following variables:

DB_ROOT_PASS=S3cur3R00tP@ss
DB_NAME=wordpress
DB_USER=wpuser
DB_PASS=WpUs3rP@ss2026

Create docker-compose.yml

Open the compose file:

vi docker-compose.yml

Add the full stack definition with health checks and dependency conditions:

services:
  db:
    image: mariadb:11
    container_name: wp-mariadb
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASS}
      MARIADB_DATABASE: ${DB_NAME}
      MARIADB_USER: ${DB_USER}
      MARIADB_PASSWORD: ${DB_PASS}
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - wp-net
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 3

  redis:
    image: redis:7-alpine
    container_name: wp-redis
    restart: unless-stopped
    volumes:
      - redis_data:/data
    networks:
      - wp-net
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3

  wordpress:
    image: wordpress:6-apache
    container_name: wp-app
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "8080:80"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME: ${DB_NAME}
      WORDPRESS_DB_USER: ${DB_USER}
      WORDPRESS_DB_PASSWORD: ${DB_PASS}
    volumes:
      - wp_data:/var/www/html
    networks:
      - wp-net

volumes:
  db_data:
  redis_data:
  wp_data:

networks:
  wp-net:
    driver: bridge

Key things to notice: the depends_on block uses condition: service_healthy, so WordPress waits until both MariaDB and Redis pass their health checks before starting. The ${DB_NAME} syntax pulls values from the .env file automatically.

Bring the Stack Up

Start all three services in detached mode:

docker compose up -d

Compose pulls the images, creates the network, volumes, and starts containers in dependency order. Check the running services:

docker compose ps

All three containers should show as running with health status:

NAME         IMAGE                COMMAND                  SERVICE     CREATED          STATUS                    PORTS
wp-app       wordpress:6-apache   "docker-entrypoint.s…"   wordpress   17 seconds ago   Up 6 seconds              0.0.0.0:8080->80/tcp
wp-mariadb   mariadb:11           "docker-entrypoint.s…"   db          17 seconds ago   Up 16 seconds (healthy)   3306/tcp
wp-redis     redis:7-alpine       "docker-entrypoint.s…"   redis       17 seconds ago   Up 16 seconds (healthy)   6379/tcp

View recent log output from all services:

docker compose logs --tail=5

The MariaDB logs should confirm “ready for connections” and WordPress should show Apache starting on port 80:

wp-mariadb  | 2026-04-14  8:43:42 0 [Note] mariadbd: ready for connections.
wp-mariadb  | Version: '11.8.6-MariaDB-ubu2404'  socket: '/run/mysqld/mysqld.sock'  port: 3306
wp-redis    | 1:M 14 Apr 2026 08:43:35.218 * Ready to accept connections tcp
wp-app      | [Tue Apr 14 08:43:46.327088 2026] [mpm_prefork:notice] [pid 1:tid 1] AH00163: Apache/2.4.66 (Debian) PHP/8.3.30 configured -- resuming normal operations

Run Commands Inside Containers

Use docker compose exec to run commands inside a running container. Verify the database was created:

docker compose exec db mariadb -u wpuser -pWpUs3rP@ss2026 -e "SHOW DATABASES;"

The output shows the wordpress database exists:

Database
information_schema
wordpress

WordPress is accessible at http://10.0.1.50:8080. Open a browser and you should see the WordPress installation wizard.

Lifecycle Commands

Stop and remove all containers (volumes are preserved):

docker compose down

Compose stops containers in reverse dependency order, removes them, and tears down the network:

 Container wp-app Stopping
 Container wp-app Removed
 Container wp-redis Removed
 Container wp-mariadb Removed
 Network wordpress-stack_wp-net Removed

Bring everything back up. Since volumes persist, your data is intact:

docker compose up -d

Restart a single service without touching the others:

docker compose restart wordpress

To destroy everything including volumes (this deletes all data):

docker compose down -v

Example 2: Flask API with Nginx and PostgreSQL

This example builds a custom Docker image using docker compose build, sets up an Nginx reverse proxy in front of a Python Flask API, and connects to PostgreSQL 17.

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

Write the Flask Application

Create the Python application:

vi app/app.py

Add a simple API with a health endpoint that queries PostgreSQL:

from flask import Flask, jsonify
import psycopg2
import os

app = Flask(__name__)

def get_db():
    return psycopg2.connect(
        host=os.environ["DB_HOST"],
        database=os.environ["DB_NAME"],
        user=os.environ["DB_USER"],
        password=os.environ["DB_PASS"]
    )

@app.route("/")
def index():
    return jsonify({"status": "running", "app": "flask-demo"})

@app.route("/health")
def health():
    try:
        conn = get_db()
        cur = conn.cursor()
        cur.execute("SELECT version();")
        version = cur.fetchone()[0]
        cur.close()
        conn.close()
        return jsonify({"db": "connected", "version": version})
    except Exception as e:
        return jsonify({"db": "error", "detail": str(e)}), 500

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

Create the Dockerfile

The Flask service uses a custom image. Create the requirements file first:

vi app/requirements.txt

Add the Python dependencies:

flask==3.1.1
psycopg2-binary==2.9.10
gunicorn==23.0.0

Now create the Dockerfile:

vi app/Dockerfile

Keep it minimal with a slim Python base:

FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 5000
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:5000", "app:app"]

Configure the Nginx Reverse Proxy

Create the Nginx configuration that proxies requests to the Flask container:

vi nginx.conf

Add the upstream and server blocks:

upstream flask_app {
    server flask:5000;
}
server {
    listen 80;
    server_name _;
    location / {
        proxy_pass http://flask_app;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Docker’s internal DNS resolves the flask hostname to the Flask container’s IP automatically.

Create the Compose File

Set up the environment file:

vi .env

Add the database credentials:

DB_NAME=flaskapp
DB_USER=flaskuser
DB_PASS=Fl@skP@ss2026

Now create the compose file:

vi docker-compose.yml

Define all three services with the build context for Flask:

services:
  postgres:
    image: postgres:17
    container_name: flask-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASS}
    volumes:
      - pg_data:/var/lib/postgresql/data
    networks:
      - app-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 3

  flask:
    build: ./app
    container_name: flask-app
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      DB_HOST: postgres
      DB_NAME: ${DB_NAME}
      DB_USER: ${DB_USER}
      DB_PASS: ${DB_PASS}
    networks:
      - app-net

  nginx:
    image: nginx:alpine
    container_name: flask-nginx
    restart: unless-stopped
    depends_on:
      - flask
    ports:
      - "8090:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    networks:
      - app-net

volumes:
  pg_data:

networks:
  app-net:
    driver: bridge

Notice the build: ./app directive. Instead of pulling a pre-built image, Compose builds the Flask image from the Dockerfile in the app/ directory.

Build and Start the Stack

Build the custom image and start everything:

docker compose up -d --build

The --build flag forces a rebuild of the Flask image even if a cached version exists. Check the services:

docker compose ps

All three containers should be running:

NAME             IMAGE               COMMAND                  SERVICE    CREATED          STATUS                    PORTS
flask-app        flask-stack-flask   "gunicorn -w 2 -b 0.…"   flask      17 seconds ago   Up 6 seconds              5000/tcp
flask-nginx      nginx:alpine        "/docker-entrypoint.…"   nginx      17 seconds ago   Up 6 seconds              0.0.0.0:8090->80/tcp
flask-postgres   postgres:17         "docker-entrypoint.s…"   postgres   17 seconds ago   Up 17 seconds (healthy)   5432/tcp

Test the API through the Nginx proxy:

curl -s http://localhost:8090/

The response confirms Flask is running behind Nginx:

{"app":"flask-demo","status":"running"}

Hit the health endpoint to verify the PostgreSQL connection:

curl -s http://localhost:8090/health

The response includes the PostgreSQL version, confirming the database link works:

{"db":"connected","version":"PostgreSQL 17.9 (Debian 17.9-1.pgdg13+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit"}

Volume and Network Management

Compose creates named volumes and networks with a project prefix (the directory name). List all volumes:

docker volume ls

Each stack gets its own volumes:

DRIVER    VOLUME NAME
local     flask-stack_pg_data
local     wordpress-stack_db_data
local     wordpress-stack_redis_data
local     wordpress-stack_wp_data

List the networks:

docker network ls

Each compose project creates an isolated bridge network:

NETWORK ID     NAME                     DRIVER    SCOPE
bf0d65c2b6c6   bridge                   bridge    local
fa0188734325   flask-stack_app-net      bridge    local
d3322f0317bf   host                     host      local
8310049ca519   wordpress-stack_wp-net   bridge    local

Services within the same compose network can reach each other by service name (for example, flask connects to postgres by hostname). Services in different compose projects are isolated from each other by default.

Useful Docker Compose Commands

Here is a quick reference for the commands you will use most often.

Validate the compose file syntax without starting anything:

docker compose config

View running processes inside each container:

docker compose top

This shows PID, user, and command for every process:

SERVICE    UID       PID   PPID  C   STIME  TTY  TIME      CMD
wordpress  root      9437  9414  0   08:43  ?    00:00:00  apache2 -DFOREGROUND
wordpress  www-data  9545  9437  0   08:43  ?    00:00:00  apache2 -DFOREGROUND
db         999       9153  9107  0   08:43  ?    00:00:01  mariadbd
redis      999       9156  9108  0   08:43  ?    00:00:00  redis-server *:6379

Follow logs in real time from a specific service:

docker compose logs -f wordpress

List all images used by the current compose project:

docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"

The output shows image sizes, which matters for deployment pipelines:

REPOSITORY          TAG        SIZE
flask-stack-flask   latest     219MB
nginx               alpine     93.5MB
wordpress           6-apache   1.08GB
mariadb             11         465MB
postgres            17         645MB
redis               7-alpine   61.2MB
Docker Compose running WordPress and Flask stacks on Ubuntu 26.04
Docker Compose v5.1.2 running six containers across two stacks on Ubuntu 26.04 LTS

Understanding Health Checks

Health checks are not optional in production compose files. Without them, depends_on only waits for the container to start, not for the service inside it to be ready. The MariaDB container starts in about 2 seconds, but the database engine takes another 5 to 10 seconds to initialize InnoDB and accept connections.

The health check configuration has four parameters:

  • test: the command to run inside the container
  • interval: how often to run it (10s is reasonable for most services)
  • timeout: how long to wait for the command to complete
  • retries: how many consecutive failures before marking unhealthy

For MariaDB, the official image ships with healthcheck.sh that checks both connectivity and InnoDB initialization. For PostgreSQL, pg_isready is the standard tool. For Redis, a simple redis-cli ping works.

Check health status of all running containers:

docker ps --format "table {{.Names}}\t{{.Status}}"

Healthy containers show “(healthy)” in the status column:

NAMES            STATUS
wp-app           Up 2 minutes
wp-redis         Up 2 minutes (healthy)
wp-mariadb       Up 2 minutes (healthy)
flask-nginx      Up 3 minutes
flask-postgres   Up 3 minutes (healthy)

Production Hardening

The examples above work for development. Production deployments need tighter controls around restart behavior, resource limits, logging, and security. Add these to your service definitions.

Restart Policies

The unless-stopped policy used in the examples restarts containers after crashes and after host reboots, but not if you explicitly stop them with docker compose stop. For a daemonless alternative that runs containers as regular processes, Podman with podman-compose supports the same compose file format. For critical services, use always instead. The key difference: always restarts even after a manual stop when the Docker daemon restarts.

services:
  db:
    image: mariadb:11
    restart: always
    deploy:
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
        window: 120s

Resource Limits

Without memory limits, a single container can consume all host memory and trigger the OOM killer. Set explicit limits under the deploy key:

services:
  wordpress:
    image: wordpress:6-apache
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M

The reservations block guarantees a minimum allocation. The limits block caps maximum usage. If a container exceeds its memory limit, Docker kills it and the restart policy brings it back.

Logging Configuration

Docker’s default json-file logging driver writes unlimited logs. On a busy service, log files grow until the disk is full. Set rotation limits:

services:
  nginx:
    image: nginx:alpine
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

This caps each service at 30 MB of logs (3 files of 10 MB each). For centralized logging, switch to the syslog or fluentd driver.

Security Options

Drop unnecessary Linux capabilities and disable privilege escalation:

services:
  flask:
    build: ./app
    security_opt:
      - no-new-privileges:true
    read_only: true
    tmpfs:
      - /tmp

The no-new-privileges flag prevents processes inside the container from gaining additional privileges via setuid or setgid binaries. The read_only flag makes the root filesystem read-only, which blocks many common attack vectors. The tmpfs mount gives the application a writable /tmp without exposing the rest of the filesystem.

For database containers, you cannot use read_only: true because they need to write to their data directories. Use no-new-privileges and explicit volume mounts instead.

Related Articles

Desktop Install Guacamole Remote Desktop on Ubuntu 22.04 (Jammy Jellyfish) Programming Install Visual Studio Code on Ubuntu 24.04 / Debian 13 Containers Install Kubernetes Cluster using Talos Container Linux Containers How To Install Podman on Ubuntu 24.04 (Noble Numbat)

Leave a Comment

Press ESC to close