#

Docker

Subscribe
Docker October 29, 2026·10 min read

Docker Compose for Web Apps: A Working Two-Service Guide

Use Docker Compose when your web app needs a repeatable, single-host definition of multiple services: connect them by service name, keep state in named volumes, and verify the web response after startup. If you need failover or orchestration across several hosts, Compose alone won't cover it. Validate your compose.yaml and run the example on a nonpublic endpoint before exposing a real site.

You'll build a small Flask app that counts visits in Redis, with one Compose file running both. The web endpoint stays on the Docker host's loopback interface, 127.0.0.1.

When Docker Compose is the right tool for a web app

Docker Compose services

Docker Compose fits a web app with several cooperating containers on one host. For a disposable single-container task, docker run is simpler. Compose describes multiple services, their networking, and their storage together. A Dockerfile only defines how an image is built.

Check the Linux host and sketch the two-service project

Linux project files

You need Docker Engine running on a Linux host, a terminal, permission to use Docker, and the Compose CLI plugin. The example runs on your own machine or on a VPS, as long as you run the local HTTP check on the Docker host.

Confirm that the modern CLI plugin is available:

docker compose version

The command confirms the Compose plugin is installed. Docker Engine must also be running to create containers. Older guides use the standalone docker-compose command.

Create a directory named compose-demo. The project will contain these files:

compose-demo/
├── app.py
├── requirements.txt
├── Dockerfile
├── compose.yaml
├── .env
├── .dockerignore
└── .gitignore

The project has two services: web, a Flask app that answers HTTP requests, and redis, which stores the visit counter. If Docker is missing, complete the Docker installation procedure first.

Define the web and Redis services in compose.yaml

Compose configuration example

You need two files: a Dockerfile that builds the web image and a compose.yaml that runs web and Redis together. Docker's Docker Compose Quickstart uses the same Flask and Redis pairing. The project below adds a host port bound to loopback and passes the Redis address through environment variables.

Create app.py:

import os

from flask import Flask
from redis import Redis

app = Flask(__name__)
cache = Redis(
    host=os.getenv("REDIS_HOST", "redis"),
    port=int(os.getenv("REDIS_PORT", "6379")),
)

@app.get("/")
def index():
    visits = cache.incr("hits")
    return f"Visits: {visits}\n"

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

Each request to / increments the Redis key hits. Flask listens on port 5000 inside its container.

Create requirements.txt:

flask
redis

The packages have no pinned versions. Now create Dockerfile, which holds the image-building instructions:

FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 5000
CMD ["python", "app.py"]
Web Redis Compose

Then create compose.yaml:

services:
  web:
    build: .
    ports:
      - "127.0.0.1:${APP_PORT:-8000}:5000"
    environment:
      REDIS_HOST: redis
      REDIS_PORT: "6379"
    depends_on:
      redis:
        condition: service_healthy

  redis:
    image: redis:alpine
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s

volumes:
  redis-data:

The web service uses build: . to build its image from the Dockerfile in the current directory. Redis pulls the redis:alpine image through image:. Those are tags, not immutable image digests.

Create .dockerignore to keep unrelated files out of the web image's build context:

.env
.git
__pycache__/
*.pyc

You don't need a top-level version: key.

Connect containers by service name

Compose network routing

A browser on the Docker host reaches Flask through the published host port. Flask reaches Redis over the Compose network, using the service name and the internal port.

Browser on Docker host
|
v
127.0.0.1:8000
|
v
web container, port 5000
|
| Compose network
v
redis service, port 6379

Inside web, the hostname redis resolves to the Redis service on the Compose network. localhost:6379 inside the web container points back at the web container itself. The Docker Compose networking documentation explains service discovery and the difference between internal and published ports.

The Redis service has no ports: declaration, so its port isn't published on the Docker host. Port 6379 stays reachable to web through the project network.

Host container ports

The HTTP mapping names the host address explicitly:

ports:
  - "127.0.0.1:${APP_PORT:-8000}:5000"

With ordinary bridge networking, that bind gives the Docker host a loopback endpoint at 127.0.0.1:8000. It serves requests made on the same host, so a browser on another computer can't reach it through the VPS's public IP address. One exception: Docker Engine versions older than 28.0.0 exposed localhost-published ports to other hosts on the same Layer 2 network.

A port published without a host IP can be reachable through other interfaces. Docker manages its own firewall rules, so a UFW rule doesn't reliably block a port that Docker publishes.

Keep application data when containers are replaced

Docker Redis volume

Redis keeps its data in a named volume that Compose reuses when the container is recreated. The service mount is:

volumes:
  - redis-data:/data

The top-level declaration defines the resource:

volumes:
  redis-data:

A container's writable layer disappears when the container is removed. A named volume outlives the container, so the new Redis container picks up the stored data.

On a throwaway copy of the project, request http://127.0.0.1:8000/ and note the counter. From the project directory, run docker compose down, then docker compose up -d, and request the page again once the services are ready. The counter should continue increasing if Redis persisted the earlier writes to the named volume.

Persistent data backups

A volume on the same host is not a backup. In a November 2024 r/selfhosted discussion, an operator managing several applications wrote, “Due to an error that snap docker had decided to delete it without first making a backup of the portainer volume, I lost all the docker compose.”

Keep independent copies of both your deployment definitions and your data. The full Docker backup and restore procedure is a task of its own.

Put configuration in the right place and keep secrets out of Git

Compose environment secrets

Compose gives you several ways to supply variables, configure containers, and deliver credentials. For this example, create a .env file with a nonsecret host-port value:

APP_PORT=8000

Compose interpolates that value into the ports: expression. Flask needs no APP_PORT variable because it listens on its container port.

MechanismWhat it controlsImportant condition
.envValues used for Compose interpolationPlaintext, with interpolation rules. It does not encrypt values
environmentVariables delivered to a containerValues can be visible through configuration or environment inspection
env_fileContainer variables loaded from a specified fileContainer-variable precedence and file handling apply
secretsFiles granted to specified servicesThe application must support reading the supplied file

People do put real credentials in these files. One poster in a September 2025 r/selfhosted discussion about per-application files in Git wrote, “My .env files have secrets in them. DB Passwords, API keys, etc.”

Add a .gitignore file to keep an untracked local .env out of Git:

.env

.dockerignore controls which files enter a Docker build context. .gitignore controls which untracked files Git ignores. Adding a tracked file to .gitignore does not remove it from the Git index or erase copies already committed. Review what Git has tracked before sharing a repository that contains credentials.

Service secret files

Docker's Compose secrets guide describes secrets granted explicitly to a service and mounted inside Linux containers at:

/run/secrets/<secret_name>

Images that document a file-based option, such as a *_FILE variable, can read those files directly. An application that expects only an environment variable needs a compatible change before it can use a secret file.

This Flask/Redis counter needs no password, so the example has no placeholder credential. On a real project, the rendered output of docker compose config can contain interpolated values. The quiet validation command in the launch section avoids printing that output on success.

Make dependent services wait for real startup readiness

Compose service health

Compose can start Redis before Flask and wait until Redis passes its health check.

The Redis service uses this health check:

healthcheck:
  test: ["CMD", "redis-cli", "ping"]
  interval: 5s
  timeout: 3s
  retries: 5
  start_period: 10s

Next, the web service declares the dependency:

depends_on:
  redis:
    condition: service_healthy

The condition makes Compose wait for the dependency's health test. Short-form depends_on only controls startup order, with no readiness gate.

In this example, redis-cli ping checks for a PONG response.

Dependency startup order

The health check only gates the first startup. If Redis fails later, the web app needs its own retry logic.

Validate the file, launch the stack, and test the web response

Compose deployment checks

The check that counts is an HTTP response from Flask through the published port. First validate and start the project from its directory:

docker compose config -q
docker compose up -d
docker compose ps

config -q validates the file, up -d starts the services, and ps shows their state.

From the Docker host, request the local web endpoint:

curl http://127.0.0.1:8000/

On a fresh start you'll see Visits: 1. Run it again and you'll see Visits: 2.

Flask HTTP response

Diagnose the first failure

Compose failure diagnosis
SymptomFirst safe checkLikely cause
Compose rejects the fileRead the reported field or interpolation errorYAML or configuration
Host port cannot bindInspect port assignment and listening addressHost port collision
Flask cannot reach RedisCompare REDIS_HOST with service name redis and port 6379Service DNS or network
Redis stays unhealthyInspect health-test result and Redis service logsDependency readiness

For instance, if a throwaway copy of the app uses REDIS_HOST=missing-service, the hostname no longer matches the Compose service. Setting it back to redis fixes that error.

Inspect only the service involved:

docker compose logs web
docker compose logs redis
Redis connection diagnosis

To test the connection from inside the web container:

docker compose exec web python -c "import redis; r = redis.Redis(host='redis'); print(r.ping())"

A successful connection and PING print True. If you need an interactive shell, docker compose exec web sh opens one in the running web container when the image includes a shell.

Fix whatever the logs point to.

Stop, change, and separate Compose projects

Compose lifecycle commands
CommandContainer effectNamed-volume effect
docker compose stopStops existing service containersRetains volumes
docker compose downRemoves service containers and ordinary project networksRetains declared named volumes by default
docker compose down -vRemoves project containers and networksRemoves declared named volumes and attached anonymous volumes

Don't run docker compose down -v on a project whose data you want to keep.

The Dockerfile copies app.py into the image, so after you change it, rebuild and recreate the web service from the project directory:

docker compose up -d --build web

Compose then runs the updated web image and keeps the project's declared volumes.

Compose project names

Name each project on a shared VPS

On a VPS running two unrelated Compose applications, project names keep each app's resources apart. Compose picks the project name in this order: the -p command option, COMPOSE_PROJECT_NAME, top-level name:, the Compose project directory name, and finally the current directory name when no configuration file is specified.

Explicitly named or external networks and volumes can collide across projects. Before removing anything, identify the effective project and its volume declarations so the cleanup targets the right application.

Know what Compose does not provide for a public site

Public deployment requirements

A loopback-bound two-container demo teaches application wiring. Public hosting also needs choices about serving software, TLS termination, credentials, access controls, updates, monitoring, and recoverable backups.

The example runs Flask's development server, and Docker's Flask tutorial does the same. Neither is meant for public production use. Before you go internet-facing, switch to a production server.

Web hosting boundaries

Compose stays useful in production once those needs are covered. Cluster-level failover across several hosts takes orchestration or deployment infrastructure. For WordPress, the WordPress with Docker Compose guide is the better recipe.

NoScript Team