Secure a Multi-Container Docker Stack

Harden a Flask and Redis stack with secrets, health checks, and monitoring.

Introduction

30 Second Summary

Every production team runs containers. But running them securely is a different story. Without hardening, a single compromised container can expose passwords, consume unlimited resources, or give attackers root access to your host.

In this project, you will build a secure, monitored two-service web application stack using Docker on an AWS EC2 instance. You'll harden containers with non-root users, read-only filesystems, and secrets management, then monitor health and resource usage in real time.

What You'll Build

A Python Flask app connected to a Redis backend, deployed as a hardened multi-container stack with health checks, network isolation, and live monitoring.

By the end of this project, you'll have:

  • A hardened Dockerfile that runs as a non-root user with a built-in health check.
  • A Docker Compose stack using secrets, network isolation, read-only filesystems, and health-based startup ordering.
  • A live monitoring workflow using Docker native tools to inspect container health, resource usage, and logs.
  • Secret Mission: Add cAdvisor as a third container for a real-time metrics dashboard showing CPU, memory, and network usage.

Are there any prerequisites?

You'll need an AWS account, PuTTY installed for SSH access, and basic command-line experience. No prior Docker knowledge is required.

Launch EC2 and Install Docker

To run Docker containers the way production teams do, you need a clean Linux server. Your local Windows machine does not natively support Docker without WSL2, so you will spin up a cloud instance instead.

In this step, you will launch an EC2 instance running Amazon Linux 2023, connect to it via PuTTY, and install Docker with the Docker Compose plugin so you have a production-ready container environment.

In this step, get ready to:
  • Launch an EC2 instance with a security group that allows SSH and HTTP traffic.
  • Install Docker and the Docker Compose plugin on the instance.
  • Verify both tools work without sudo.
Launch an EC2 instance and connect via PuTTY

You need a Linux server to run your Docker stack. AWS offers free-tier-eligible instances that are perfect for this.

  • Sign in to the AWS Management Console at https://console.aws.amazon.com/ec2/.
  • In the navigation bar at the top, confirm you are in a Region close to you (for example, US East).
  • Click Launch instance from the EC2 dashboard.
  • Under Name and tags, enter docker-secure-lab in the Name field.
  • Under Application and OS Images (Amazon Machine Image), choose Quick Start, then select Amazon Linux. Confirm that Amazon Linux 2023 AMI is selected and marked Free tier eligible.
  • Under Instance type, select t2.micro (or t3.micro if t2.micro is not available in your Region). Both are free-tier eligible.
  • Under Key pair (login), click Create new key pair.
  • Name it docker-lab-key, set Private key file format to ppk (for PuTTY), then click Create key pair. Save the downloaded .ppk file somewhere you can find it.
  • Under Network settings, click Edit.
  • Keep the default SSH rule (port 22) and change its source to My IP.
  • Click Add security group rule. Set Type to Custom TCP, Port range to 8080, and Source to My IP.
  • In the Summary panel on the right, click Launch instance.
  • Wait for the success banner, then click the instance ID link to open the Instances page.
  • Wait until the Instance state column shows Running and Status check shows passed.
  • Select your instance and copy the Public IPv4 DNS value from the details panel at the bottom of the page.

What is EC2?

EC2 (Elastic Compute Cloud) is a virtual server in AWS. You are renting a Linux machine in the cloud. The security group acts as a firewall controlling which network traffic can reach your instance.

Port 22 lets you connect via SSH (PuTTY). Port 8080 lets you reach the web app you will deploy later.

Now connect to your instance using PuTTY.

  • Press the Windows key to open your search bar.
  • Type PuTTY and press Enter to open it.
  • In the Host Name box, type ec2-user@ followed by the Public IPv4 DNS you copied (for example, ec2-user@ec2-12-34-56-78.compute-1.amazonaws.com).
  • Confirm Port is set to 22 and Connection type is SSH.
  • In the left Category pane, expand Connection, then SSH, then Auth. Click Credentials.
  • Next to Private key file for authentication, click Browse and select the docker-lab-key.ppk file you downloaded.
  • Click Open to connect.
  • PuTTY will display a security alert about the host key. Click Accept to continue.

You should see a terminal prompt ending in $. You are now connected to your EC2 instance as ec2-user.

Can't connect to your instance?

  • Make sure the instance state is Running and status checks have passed. It can take a minute or two after launch.
  • Confirm you chose My IP as the SSH source in your security group. If your IP changed, edit the security group inbound rules in the EC2 console.
  • Double-check that you selected the correct .ppk file in PuTTY's Auth Credentials section.

Still stuck? Help me troubleshoot my PuTTY connection to EC2.

Install Docker and the Docker Compose plugin

Your EC2 instance is a blank slate. You need to install the Docker engine and the Compose plugin before you can run containers.

  • Update your system packages and install Docker by running these commands:
sudo yum update -y
sudo yum install -y docker
sudo service docker start
sudo usermod -a -G docker ec2-user

What do these commands do?

  • `sudo yum update -y` updates all installed packages to their latest versions.
  • `sudo yum install -y docker` installs the Docker engine from the Amazon Linux repositories.
  • `sudo service docker start` launches the Docker daemon so it can manage containers.
  • `sudo usermod -a -G docker ec2-user` adds your user account to the docker group so you can run Docker commands without sudo.
  • You should see output confirming each package installed successfully. No errors should appear.
  • Install the Docker Compose plugin by running these commands:
mkdir -p $HOME/.docker/cli-plugins
curl -SL https://github.com/docker/compose/releases/download/v5.1.2/docker-compose-linux-x86_64 -o $HOME/.docker/cli-plugins/docker-compose
chmod +x $HOME/.docker/cli-plugins/docker-compose

What do these commands do?

  • `mkdir -p $HOME/.docker/cli-plugins` creates the directory where Docker looks for CLI plugins.
  • The curl command downloads the Docker Compose v5.1.2 binary from GitHub.
  • `chmod +x` makes the downloaded file executable so Docker can run it as a plugin.
  • The group change from usermod does not take effect until you start a new session. Close your PuTTY window completely.
  • Reopen PuTTY and connect again using the same settings (Host Name, key file, port 22).

Getting permission denied when running docker?

  • The most common cause is forgetting to disconnect and reconnect after the usermod command. Close PuTTY completely and open a brand new session.
  • If you still get errors, verify you are in the docker group by running groups in the terminal. You should see docker in the output list.

Still stuck? Help me fix Docker permission issues on my EC2 instance.

Verify Docker is working without sudo

After reconnecting, confirm both Docker and Docker Compose respond to your user without sudo.

  • Check that the Docker engine is running by running this command:
docker info
  • You should see a long output with details like Server Version, Storage Driver, and Operating System: Amazon Linux 2023. This confirms Docker is running.
  • Verify the Docker Compose plugin by running this command:
docker compose version
  • You should see output showing Docker Compose version v5.1.2. This confirms the plugin is installed correctly.

Seeing command not found for docker compose?

  • Make sure the file is in the correct path. Run ls $HOME/.docker/cli-plugins/ and confirm docker-compose appears.
  • Verify the file is executable by running ls -la $HOME/.docker/cli-plugins/docker-compose. You should see x in the permissions.
  • If the file is missing, re-run the curl and chmod commands from the previous substep.

Still stuck? Help me get docker compose working on my EC2 instance.

Your cloud environment is ready. Docker is running, Compose is installed, and you can execute container commands without sudo. Next up, you will write a hardened Dockerfile for a Flask application that runs as a non-root user.

Build a Hardened Flask Container

Your EC2 instance has Docker ready to go. Now it is time to build the application that will run inside your containers.

By default, containers run as the root user. This means if an attacker breaks into your container, they have full control. In this step, you will write a Flask app, then package it in a hardened Dockerfile that runs as a non-root user with a built-in health check.

In this step, get ready to:
  • Write a Flask app with a health check endpoint that reads secrets from a file.
  • Create a hardened Dockerfile with a non-root user and a HEALTHCHECK instruction.
  • Build and verify the image runs as a non-root user.
Create the project directory and Flask application

Your Flask app will have two endpoints. The main route increments a visit counter in Redis. The /health endpoint checks whether Redis is reachable, which Docker will use to determine if the container is healthy.

  • Create a project directory and move into it by running these commands:
mkdir ~/flask-secure-app
cd ~/flask-secure-app

What does this create?

This creates a flask-secure-app folder in your home directory and moves you into it. All project files will live here.

  • Create app.py by pasting this into your terminal:
cat > app.py << 'EOF'
import os
from flask import Flask, jsonify
import redis

app = Flask(__name__)

# Read the Redis password from the Docker secrets mount
def get_redis_password():
    secret_path = "/run/secrets/redis_password"
    try:
        with open(secret_path, "r") as f:
            return f.read().strip()
    except FileNotFoundError:
        return None

# Create a Redis client using the secret password
def get_redis_client():
    password = get_redis_password()
    return redis.Redis(
        host="redis",
        port=6379,
        password=password,
        decode_responses=True,
        socket_connect_timeout=2
    )

# Main route - increments a visit counter in Redis
@app.route("/")
def index():
    r = get_redis_client()
    count = r.incr("hits")
    return jsonify({"message": "Hello from a hardened container!", "visits": count})

# Health check endpoint - verifies Redis connectivity
@app.route("/health")
def health():
    try:
        r = get_redis_client()
        r.ping()
        return jsonify({"status": "healthy", "redis": "connected"}), 200
    except Exception:
        return jsonify({"status": "unhealthy", "redis": "disconnected"}), 503

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

What does this code do?

get_redis_password() reads the password from /run/secrets/redis_password instead of an environment variable. This is where Docker mounts secrets as files, keeping credentials out of docker inspect output.

The /health endpoint pings Redis and returns a JSON status. Docker will call this endpoint repeatedly to decide whether the container is healthy or unhealthy.

  • Create requirements.txt with the pinned dependencies by running:
cat > requirements.txt << 'EOF'
flask==3.1.3
redis==6.3.0
EOF

Why pin exact versions?

Pinning to flask==3.1.3 and redis==6.3.0 ensures your image builds the same way every time. Without pinning, a new library release could introduce breaking changes into your container.

  • Confirm both files exist by running:
ls

You should see app.py and requirements.txt in the output.

Don't see the files?

Make sure you pressed Enter after pasting each cat command. The EOF on the last line must appear on its own line with no spaces before it.

If you see a > prompt instead of returning to $, the heredoc did not close. Type EOF and press Enter to finish it.

help me create files on my EC2 instance using heredoc commands

Write the Dockerfile

The Dockerfile defines how your image is built. You will start from a slim Python base, create a non-root user, install dependencies, and add a health check that Docker monitors automatically.

  • Create the Dockerfile by pasting this into your terminal:
cat > Dockerfile << 'EOF'
FROM python:3.12-slim

# Create a non-root user for running the application
RUN groupadd -r appuser && useradd --no-log-init -r -g appuser -u 1000 appuser

WORKDIR /app

# Install Python dependencies first (better layer caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY app.py .

# Give ownership to the non-root user
RUN chown -R appuser:appuser /app

# Switch to non-root user from this point forward
USER appuser

EXPOSE 5000

# Built-in health check that tests the /health endpoint
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')" || exit 1

CMD ["python", "app.py"]
EOF

What does this Dockerfile do?

FROM python:3.12-slim starts from a minimal Python image. The slim variant excludes compilers and extra libraries, reducing the attack surface.

RUN groupadd ... && useradd ... creates a dedicated user called appuser with UID 1000. The USER appuser line switches all subsequent commands (and the running container) to this non-root user.

The HEALTHCHECK instruction tells Docker to call the /health endpoint every 10 seconds. If it fails 3 times in a row, Docker marks the container as unhealthy.

  • Verify the Dockerfile exists by running:
ls

You should now see app.py, Dockerfile, and requirements.txt in the output.

Missing the Dockerfile?

Check that you are inside the ~/flask-secure-app directory. Run pwd to confirm your current location. If you are somewhere else, run cd ~/flask-secure-app and try the cat command again.

help me with my Dockerfile heredoc on EC2

Build the image and verify the non-root user

With all three files in place, you can build the Docker image. The build process installs your dependencies and bakes in the non-root user configuration.

  • Build the image by running:
docker build -t flask-secure .

What does this command do?

docker build reads the Dockerfile in the current directory (.) and executes each instruction to produce an image. The -t flask-secure flag tags the resulting image with the name flask-secure so you can reference it later.

The build takes about 30 seconds as it downloads the base image and installs Flask and redis-py. You should see output ending with a line that includes naming to docker.io/library/flask-secure.

Build failed with an error?

If you see a file-not-found error, confirm you are inside ~/flask-secure-app and that all three files (app.py, requirements.txt, Dockerfile) exist. Run ls to check.

If you see a network error during pip install, your EC2 security group may be missing an outbound rule. Check that outbound traffic is allowed on all ports.

help me fix my Docker build error on EC2

  • Verify the container runs as a non-root user by running:
docker run --rm flask-secure whoami

You should see appuser printed to the terminal. This confirms your container does not run as root.

Why does this matter?

If an attacker exploits a vulnerability in your Flask app, they land inside the container as appuser. This user has no permission to install packages, modify system files, or escalate privileges. Running as root would give an attacker full control of the container.

Seeing root instead of appuser?

Double-check your Dockerfile has the USER appuser line near the bottom, after the RUN chown line. If it is missing, add it and rebuild with docker build -t flask-secure . again.

help me fix my Dockerfile so the container runs as a non-root user

✔️ Awesome, I've got everything!

Great. Double check that you have saved all three files and that docker run --rm flask-secure whoami outputs appuser.

ⓧ I'd like to double check the full code

Here are all three files for comparison.

app.py:

import os
from flask import Flask, jsonify
import redis

app = Flask(__name__)

def get_redis_password():
    secret_path = "/run/secrets/redis_password"
    try:
        with open(secret_path, "r") as f:
            return f.read().strip()
    except FileNotFoundError:
        return None

def get_redis_client():
    password = get_redis_password()
    return redis.Redis(
        host="redis",
        port=6379,
        password=password,
        decode_responses=True,
        socket_connect_timeout=2
    )

@app.route("/")
def index():
    r = get_redis_client()
    count = r.incr("hits")
    return jsonify({"message": "Hello from a hardened container!", "visits": count})

@app.route("/health")
def health():
    try:
        r = get_redis_client()
        r.ping()
        return jsonify({"status": "healthy", "redis": "connected"}), 200
    except Exception:
        return jsonify({"status": "unhealthy", "redis": "disconnected"}), 503

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

requirements.txt:

flask==3.1.3
redis==6.3.0

Dockerfile:

FROM python:3.12-slim

RUN groupadd -r appuser && useradd --no-log-init -r -g appuser -u 1000 appuser

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

RUN chown -R appuser:appuser /app

USER appuser

EXPOSE 5000

HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')" || exit 1

CMD ["python", "app.py"]

Your hardened image is ready. Next up, you will wire this container to Redis using Docker Compose, with secrets management, health checks controlling startup order, and network isolation locking down traffic.

Compose with Health Checks, Secrets, and Network Isolation

Your hardened Flask image is built and confirmed running as a non-root user. Now it's time to orchestrate multiple containers together into a production-style stack.

Docker Compose lets you define multi-container applications in a single YAML file. In this step, you'll wire up a Flask container and a Redis backend with health checks that control startup order, secrets that keep passwords out of environment variables, and network isolation that locks Redis away from the outside world.

In this step, get ready to:
  • Create a secret file and a Compose definition with two services and two isolated networks.
  • Configure health checks and startup ordering so the web container waits for Redis.
  • Launch the stack and verify both containers are healthy and responding.
Create the secret file and Compose definition

Docker Compose secrets mount a password file into the container at a secure path instead of passing credentials through environment variables. You need a file containing the Redis password before you can reference it in your Compose definition.

  • Create the redis_password.txt file by running this command:
echo 's3cur3-r3d1s-p@ss' > redis_password.txt

Why a file instead of an environment variable?

Environment variables are visible to any process inside the container and show up in docker inspect output. Secrets mounted as files at /run/secrets/ are only readable by the container's own processes and never appear in logs or inspection commands.

Now you'll create the compose.yaml file that defines your entire multi-container stack. This single file declares both services, their networks, filesystem restrictions, health checks, startup order, and secrets.

  • Create compose.yaml by running this command:
cat > compose.yaml << 'EOF'
services:
  web:
    build: .
    image: flask-secure
    ports:
      - "8080:5000"
    read_only: true
    tmpfs:
      - /tmp
    networks:
      - frontend
      - backend
    depends_on:
      redis:
        condition: service_healthy
    secrets:
      - redis_password
    healthcheck:
      test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:5000/health')\""]
      interval: 10s
      timeout: 3s
      start_period: 5s
      retries: 3

  redis:
    image: redis:7-alpine
    read_only: true
    tmpfs:
      - /data
    networks:
      - backend
    secrets:
      - redis_password
    command: >
      sh -c 'redis-server --requirepass "$$(cat /run/secrets/redis_password)"'
    healthcheck:
      test: ["CMD-SHELL", "redis-cli -a $$(cat /run/secrets/redis_password) ping | grep PONG"]
      interval: 5s
      timeout: 3s
      start_period: 5s
      retries: 5

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true

secrets:
  redis_password:
    file: ./redis_password.txt
EOF

What does this Compose file do?

This file defines a complete multi-container stack with two services, two networks, and one secret:

  • web builds your hardened Flask image, maps port 8080 on the host to port 5000 inside the container, and connects to both the frontend and backend networks.
  • redis uses the official redis:7-alpine image and connects ONLY to the backend network. It has no published ports, making it completely unreachable from outside.
  • read_only: true on both services makes the container filesystem immutable. The tmpfs mounts provide writable paths in memory for /tmp (Flask) and /data (Redis) without persisting anything to disk.
  • The backend network uses internal: true, which blocks all external internet access for containers on that network.
  • Confirm both files exist by running:
ls

You should see app.py, compose.yaml, Dockerfile, redis_password.txt, and requirements.txt in the output.

Missing files in the output?

Make sure you ran both commands from inside your project directory. If compose.yaml is missing, the heredoc command may not have completed. Check that you see the $ prompt (not >) before continuing.

Help me figure out why my files are missing.

Understand health checks, startup order, and secrets

Your Compose file packs several production patterns into a compact definition. Let's break down the three most important sections: health checks, startup ordering, and secrets.

How do the health checks work?

Each service has a healthcheck block that tells Docker how to verify the container is actually working, not just running.

  • Redis uses redis-cli ping piped through grep PONG. If Redis responds with PONG, the check passes.
  • Web uses a Python one-liner that hits the /health endpoint. This verifies both that Flask is running AND that it can reach Redis.

Docker runs these checks on a schedule (every 5s for Redis, every 10s for web) and marks the container as healthy or unhealthy based on the results.

What does depends_on with service_healthy do?

The depends_on section with condition: service_healthy tells Docker Compose to hold back the web container until Redis passes its health check.

Without this condition, Docker would start both containers simultaneously. The Flask app would crash trying to connect to a Redis instance that isn't ready yet.

How are secrets wired through the stack?

Secrets flow through three layers in the Compose file:

  • The top-level secrets: block points to the source file (redis_password.txt).
  • Each service's secrets: attribute grants it access to the secret.
  • Inside the container, the password is readable at /run/secrets/redis_password.

Redis reads the password at startup with $$(cat /run/secrets/redis_password). The $$ is Compose's escape for a literal dollar sign so the shell inside the container expands it at runtime.

Launch the stack and verify

Everything is configured. Time to bring the stack up and confirm that Docker Compose respects the startup order and health requirements.

  • Launch the stack in detached mode by running:
docker compose up -d

Docker Compose will build the Flask image (if needed), pull the Redis image, create the networks, and start both containers in the correct order. This takes about 30 seconds the first time while it pulls redis:7-alpine.

  • Check the startup logs to confirm the ordering by running:
docker compose logs

You should see Redis log entries appearing first, followed by the Flask app starting after Redis became healthy.

  • Verify both containers show as healthy by running:
docker compose ps

Both services should show a STATUS containing (healthy) in the output.

Seeing (health: starting) instead of (healthy)?

Health checks need time to run. Wait 15-20 seconds and run docker compose ps again. The web service has a 5-second start period plus a 10-second interval, so it can take up to 15 seconds to transition to healthy.

If a container shows (unhealthy), check its logs with docker compose logs web or docker compose logs redis to see what went wrong.

Help me debug my unhealthy container.

  • Test the web application by running:
curl http://localhost:8080

You should see a JSON response with a visit counter.

  • Test the health endpoint by running:
curl http://localhost:8080/health

You should see:

{"status": "healthy", "redis": "connected"}

What just happened?

Your Flask container read the Redis password from /run/secrets/redis_password, connected to the Redis container over the private backend network, incremented a counter, and returned the result. The password never appeared in an environment variable or a log.

✔️ Awesome, I've got everything!

Great. Double check you see (healthy) for both services in docker compose ps and that both curl commands return valid JSON.

ⓧ I'd like to double check the full code

Here is the complete compose.yaml for comparison:

services:
  web:
    build: .
    image: flask-secure
    ports:
      - "8080:5000"
    read_only: true
    tmpfs:
      - /tmp
    networks:
      - frontend
      - backend
    depends_on:
      redis:
        condition: service_healthy
    secrets:
      - redis_password
    healthcheck:
      test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:5000/health')\""]
      interval: 10s
      timeout: 3s
      start_period: 5s
      retries: 3

  redis:
    image: redis:7-alpine
    read_only: true
    tmpfs:
      - /data
    networks:
      - backend
    secrets:
      - redis_password
    command: >
      sh -c 'redis-server --requirepass "$$(cat /run/secrets/redis_password)"'
    healthcheck:
      test: ["CMD-SHELL", "redis-cli -a $$(cat /run/secrets/redis_password) ping | grep PONG"]
      interval: 5s
      timeout: 3s
      start_period: 5s
      retries: 5

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true

secrets:
  redis_password:
    file: ./redis_password.txt

And redis_password.txt:

s3cur3-r3d1s-p@ss

Your multi-container stack is running with health checks controlling startup order, secrets keeping credentials secure, and network isolation protecting Redis from external access. Next up, you'll learn how to monitor these containers in real time and simulate failures to see health checks in action.

Monitor Container Health and Resources

Your hardened stack is running with non-root containers, read-only filesystems, and secret-based credentials. Now it's time to observe how those protections look from the outside.

In production, teams monitor their containers constantly to catch failures early. In this step, you'll use Docker's built-in tools to inspect health, track resource usage, and watch what happens when something goes wrong.

In this step, get ready to:
  • Inspect health check results for both containers.
  • Monitor CPU, memory, and network usage in real time.
  • Simulate a Redis failure and observe automatic recovery.
Inspect container health status

Docker health checks run on a schedule and store a log of recent results. You can view the full health history of any container or get a quick overview of all services at once.

  • See the health status of all your services at a glance by running:
docker compose ps

You should see both the web and redis services listed with a healthy status in the output.

What does the output show?

The STATUS column shows whether each container is running, plus its health state in parentheses. The PORTS column confirms which host ports map to container ports.

  • Inspect the detailed health check log for the web container by running:
docker inspect --format='{{json .State.Health}}' $(docker compose ps -q web)
  • Now inspect the Redis container's health log by running:
docker inspect --format='{{json .State.Health}}' $(docker compose ps -q redis)

What's in the health log?

The JSON output contains three key fields. Status shows the current state (healthy or unhealthy). FailingStreak counts consecutive failed checks.

The Log array stores the last five check results, each with a Start timestamp, End timestamp, ExitCode (0 means success), and Output from the check command.

Seeing an empty health log?

Health checks run on a schedule. If your containers just started, wait 10-15 seconds for the first check to complete, then run the inspect command again.

help me understand why my docker inspect health log is empty

Monitor resource usage and verify secrets

Beyond health checks, Docker tracks real-time resource consumption for every running container. This lets you spot memory leaks, high CPU usage, or unusual network activity.

  • View a snapshot of resource usage for all running containers by running:
docker stats --no-stream

You should see a table with CPU percentage, memory usage, network I/O, and block I/O for both the web and redis containers.

What does docker stats show?

Each row is one container. The MEM USAGE / LIMIT column shows how much memory the container is using out of the total available. The NET I/O column shows bytes sent and received.

Notice which container uses more memory. Redis typically uses more than the minimal Flask app because it keeps data structures in memory.

Now verify that your Redis password is not leaked through environment variables. This is the whole point of using Docker secrets instead of passing credentials via ENV in your Dockerfile.

  • List all environment variables inside the web container by running:
docker compose exec web env

Scan the output. You should see standard Python and system variables, but no password or Redis credential anywhere.

  • Confirm where the secret actually lives by running:
docker compose exec web cat /run/secrets/redis_password

This outputs s3cur3-r3d1s-p@ss directly from the mounted secrets file.

Why secrets files instead of environment variables?

Environment variables show up in docker inspect, in crash dumps, and in child processes. Secrets mounted at /run/secrets/ are only visible inside the container's filesystem and never appear in logs or environment listings.

This is why you saw nothing sensitive in the env output. The app reads the credential from the file at runtime.

Permission denied reading the secret?

Make sure you are running the cat command against the web service, not redis. Both services have the secret mounted, but file permissions may differ depending on the base image.

help me troubleshoot Docker secrets file permissions

Simulate a failure and observe recovery

The real test of health checks is how the system responds when something breaks. You're going to intentionally kill the Redis container and watch the web service's health degrade in real time.

  • Stop the Redis container to simulate a backend failure by running:
docker compose stop redis

Redis is now gone. The web container is still running, but its next health check will fail because it can't reach Redis.

  • Wait about 15-20 seconds for the health check to run, then test the health endpoint by running:
curl http://localhost:8080/health

You should see {"status": "unhealthy", "redis": "disconnected"} with a 503 response. The health check has detected the missing dependency.

Why the wait?

The web service health check runs every 10 seconds with 3 retries before marking the container as unhealthy. It takes a few check cycles for the status to fully transition. If you still see a healthy response, wait another 10 seconds and try again.

  • Bring Redis back online by running:
docker compose start redis
  • Wait about 15 seconds for Redis to start and pass its own health check, then verify recovery by running:
curl http://localhost:8080/health

You should see {"status": "healthy", "redis": "connected"} with a 200 response. The system has self-healed.

  • Watch the live log stream to see the full picture of what happened by running:
docker compose logs -f

You'll see interleaved logs from both services showing the disconnection and reconnection events. Press Ctrl+C to stop following the logs when you're done.

What did you just prove?

You simulated a real failure scenario and watched the health check system respond. Without health checks, a broken Redis connection would silently produce errors for users with no automated signal.

With health checks in place, monitoring tools (and orchestrators like Kubernetes) can detect the failure and take action automatically, like restarting the unhealthy container or rerouting traffic.

Still seeing healthy after stopping Redis?

The health check needs multiple failed attempts before transitioning. Your web service retries 3 times at 10-second intervals. Wait a full 30 seconds and try curl again.

You can also confirm Redis is actually stopped by running docker compose ps and checking that the redis service shows as exited.

help me figure out why my health check isn't transitioning to unhealthy

You've just completed a full monitoring workflow. You inspected health logs, tracked resource usage, verified secret isolation, and simulated a production failure. These are the same techniques teams use to keep containerized applications reliable.

Secret mission

Add cAdvisor for Real-Time Metrics Dashboard

Your monitoring workflow so far relies on terminal commands. What if you could see CPU, memory, and network metrics for every container in a live web dashboard? In this secret mission, you'll add cAdvisor as a third service to your Compose stack and get a visual metrics dashboard running alongside your hardened app.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses an EC2 instance that consumes free tier hours while running.

Cost warning

Your EC2 instance is eligible for 750 free tier hours per month. However, leaving it running when you are not using it eats into that allowance. If you have other EC2 instances running, you could exceed the free tier and incur charges.

Stop or terminate the instance when you are done to avoid unexpected costs.

Resources you used:

  • 1x EC2 instance (t2.micro or t3.micro) running Docker containers and images.
  • Docker Compose stack with web, redis, and cadvisor services.
  • EC2 security group rules (SSH on port 22, HTTP on ports 8080 and 8081).

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the Docker stack.

  • Your EC2 instance continues to consume free tier hours while running.
  • Stop the instance from the AWS console when you are not using it to preserve hours.
  • Docker and the Compose plugin remain installed. Your containers keep running until the instance is stopped.

Pause - I'll come back to this later

Stop your EC2 instance to halt compute charges while keeping your files and configuration intact.

  • Open the EC2 console at https://console.aws.amazon.com/ec2/.
  • In the navigation pane, click Instances.
  • Select your instance.
  • Click Instance state, then Stop instance.
  • Confirm by clicking Stop.

What happens when you stop?

No compute charges while stopped. Minimal EBS storage cost continues (pennies per month). Your Docker images, project files, and Compose configuration all remain on the instance volume.

To resume, select the instance and click Instance state then Start instance. Reconnect via PuTTY and run docker compose up -d to bring the stack back up.

Delete - I don't want to use this again

Terminate your EC2 instance to permanently remove all resources and stop all charges.

  • Open the EC2 console at https://console.aws.amazon.com/ec2/.
  • In the navigation pane, click Instances.
  • Select your instance.
  • Click Instance state, then Terminate (delete) instance.
  • Click Terminate (delete) when prompted for confirmation.

Termination is permanent

Terminating deletes the instance and its attached EBS volume. All Docker images, containers, and project files on the instance are permanently lost. You cannot recover them.

The security group remains in your account but does not cost anything. You can delete it separately from the EC2 console under Network & Security then Security Groups if you want a clean slate.

Nice Work!

Nice Work!

You just built and deployed a production-hardened, multi-container Docker stack on EC2 with security best practices baked into every layer.

You've learned how to:

  • Written a hardened Dockerfile that runs as a non-root user (appuser) with a built-in HEALTHCHECK, and configured Docker Compose secrets to pass credentials via /run/secrets/ instead of environment variables.
  • Applied read_only: true, network isolation, and depends_on: condition: service_healthy to control startup order and limit the container attack surface.
  • Monitored containers in real time with docker stats, docker inspect, and docker compose logs, then simulated a failure to observe health check transitions from healthy to unhealthy and back.
  • Secret Mission: Deployed cAdvisor as a third container for a visual real-time metrics dashboard showing CPU, memory, and network usage across all containers.

Ready to quiz yourself?