Free Ebook cover Docker for Beginners: Containers Explained with Simple Projects

Docker for Beginners: Containers Explained with Simple Projects

New course

12 pages

Project: Simple API Containerization and Configuration

Capítulo 8

Estimated reading time: 10 minutes

+ Exercise

Project Goal and What You Will Build

In this project you will containerize a small HTTP API and learn how to configure it cleanly for different environments (local development vs. production-like runs) without rebuilding the image every time you change settings. You will also add health checks, structured logging basics, and safe defaults so the container behaves predictably when deployed.

The API will expose a few endpoints and read its configuration from environment variables. You will run it with Docker using a minimal set of runtime flags, and you will validate that configuration changes take effect by restarting the container rather than rebuilding the image.

Concept: Containerizing an API vs. Containerizing a Website

A static website container typically serves files and has little runtime configuration. An API container is different: it often needs runtime settings such as the port to bind, the environment name, allowed origins, database URLs, API keys, and feature flags. The key concept is separating what belongs in the image (application code and dependencies) from what belongs in runtime configuration (environment-specific values). This separation makes the same image usable across environments.

Another difference is that APIs are usually consumed by other services. That means you should think about: predictable startup, health endpoints for monitoring, and logging that is easy to collect. In containers, the simplest logging strategy is writing logs to standard output and standard error so the container runtime can capture them.

Project Setup: A Minimal API with Configurable Behavior

You can implement the API in any language, but the steps below use a small Python Flask API because it is compact and easy to read. The same configuration principles apply to Node.js, Go, Java, or .NET.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

1) Create the project folder structure

Create a new folder and add these files:

  • app.py (the API code)
  • requirements.txt (dependencies)
  • Dockerfile (image build instructions)
  • .dockerignore (exclude unnecessary files)

2) Write the API code (app.py)

This API demonstrates configuration via environment variables and includes a health endpoint. It also shows how to fail fast when required configuration is missing.

import os
import time
from flask import Flask, jsonify, request

app = Flask(__name__)

# Configuration with defaults
APP_NAME = os.getenv("APP_NAME", "simple-api")
APP_ENV = os.getenv("APP_ENV", "development")
LOG_LEVEL = os.getenv("LOG_LEVEL", "info")

# Example of a required config (uncomment to enforce)
# API_KEY = os.getenv("API_KEY")
# if not API_KEY:
#     raise RuntimeError("Missing required environment variable: API_KEY")

START_TIME = time.time()

@app.get("/")
def root():
    return jsonify({
        "name": APP_NAME,
        "env": APP_ENV,
        "message": "Hello from a containerized API"
    })

@app.get("/health")
def health():
    # Basic health endpoint: if the process is running, it's healthy
    return jsonify({"status": "ok"})

@app.get("/uptime")
def uptime():
    return jsonify({"uptime_seconds": round(time.time() - START_TIME, 2)})

@app.post("/echo")
def echo():
    payload = request.get_json(silent=True) or {}
    return jsonify({"you_sent": payload})

if __name__ == "__main__":
    # Bind to 0.0.0.0 so it is reachable from outside the container
    port = int(os.getenv("PORT", "8080"))
    debug = os.getenv("DEBUG", "false").lower() == "true"
    app.run(host="0.0.0.0", port=port, debug=debug)

Key configuration ideas shown here:

  • PORT controls the listening port inside the container.
  • APP_ENV can switch behavior (for example, enabling debug logging in development).
  • Binding to 0.0.0.0 is required so Docker can publish the port to your host.
  • A /health endpoint gives a simple way to check container readiness from outside.

3) Add dependencies (requirements.txt)

flask==3.0.3

4) Add a .dockerignore

This keeps your build context small and avoids copying local clutter into the image.

__pycache__
*.pyc
*.pyo
*.pyd
.venv
venv
.env
.git
.gitignore
Dockerfile
README.md

Note: excluding Dockerfile is optional; it does not affect the build because Docker reads it separately. You can remove that line if you prefer.

Containerization: Build an Image for the API

The goal is a small, reproducible image that runs the API as a non-root user and exposes the correct port. You will also add a container health check so Docker can report the container’s health status.

5) Create the Dockerfile

FROM python:3.12-slim

# Prevent Python from writing .pyc files and enable unbuffered logs
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

# Install dependencies first for better layer caching
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY app.py /app/

# Create and use a non-root user
RUN useradd -m appuser
USER appuser

# The app listens on 8080 by default
EXPOSE 8080

# Optional: container-level health check using the /health endpoint
# curl is not installed by default on slim images; use python to check HTTP
HEALTHCHECK --interval=10s --timeout=3s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health').read()" || exit 1

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

Why this Dockerfile is structured this way:

  • Dependencies are installed before copying the app code to maximize build cache reuse.
  • PYTHONUNBUFFERED=1 ensures logs appear immediately in container logs.
  • A non-root user reduces risk if the process is compromised.
  • The health check uses Python instead of curl to avoid installing extra packages.

6) Build the image

docker build -t simple-api:1.0 .

After building, you should have a local image named simple-api:1.0.

Run the API Container with Configuration

Now you will run the container and pass configuration values at runtime. The key learning is that you can change configuration without rebuilding the image.

7) Run with default configuration

docker run --rm -p 8080:8080 --name simple-api simple-api:1.0

Test it from your host:

curl http://localhost:8080/
curl http://localhost:8080/health
curl http://localhost:8080/uptime

8) Run with environment variables

Stop the container (Ctrl+C if attached) and run again with custom configuration:

docker run --rm -p 8080:8080 --name simple-api \
  -e APP_NAME="Simple API" \
  -e APP_ENV=production \
  -e LOG_LEVEL=warning \
  simple-api:1.0

Verify the changes:

curl http://localhost:8080/

You should see the updated name and env values in the JSON response.

9) Change the internal port safely

If you change the internal port the app listens on, you must also adjust port publishing. For example, run the app on port 9090 inside the container, but publish it to 8080 on the host:

docker run --rm --name simple-api \
  -e PORT=9090 \
  -p 8080:9090 \
  simple-api:1.0

Test again:

curl http://localhost:8080/health

Important: the Dockerfile health check currently targets http://127.0.0.1:8080/health. If you change PORT, the health check would no longer match. For a real project, you would either keep the internal port fixed (recommended) or implement a health check that reads the same port configuration. A common practice is to keep the container’s internal port stable and only vary the host port mapping.

Configuration Management Patterns for Containers

Environment variables are the most common configuration mechanism in containerized apps because they are easy to inject at runtime and work across orchestrators. Still, you need conventions to keep configuration manageable.

Pattern A: Use a .env file for local runs

Instead of typing many -e flags, create a file named local.env:

APP_NAME=Simple API (Local)
APP_ENV=development
LOG_LEVEL=debug
PORT=8080

Run with:

docker run --rm -p 8080:8080 --name simple-api --env-file local.env simple-api:1.0

This keeps local configuration in one place. Avoid committing secrets to version control; treat env files as sensitive if they contain credentials.

Pattern B: Provide defaults, but fail fast for required secrets

Some values can have safe defaults (like APP_NAME). Others should be required (like API keys). The earlier code shows how to enforce a required variable by raising an error at startup. Failing fast is better than running with an insecure or broken configuration.

Pattern C: Keep configuration out of the image

Do not bake environment-specific values into the Dockerfile using ENV unless they are truly universal defaults. If you hardcode production URLs or keys into the image, you lose portability and risk leaking secrets.

Add a Simple Runtime Configuration Endpoint (Optional but Useful)

For debugging, it can be helpful to expose a limited endpoint that shows non-sensitive configuration. This is useful when you are learning and want to confirm what the container received. Only expose safe values and consider disabling such endpoints in production.

Add this to app.py:

@app.get("/config")
def config():
    return jsonify({
        "app_name": APP_NAME,
        "app_env": APP_ENV,
        "log_level": LOG_LEVEL
    })

Rebuild the image after code changes:

docker build -t simple-api:1.1 .

Run and test:

docker run --rm -p 8080:8080 --name simple-api \
  -e APP_ENV=production \
  simple-api:1.1
curl http://localhost:8080/config

Health Checks: What They Mean and How to Validate Them

A health check is not the same as “the container is running.” A container can be running but unhealthy (for example, stuck in a bad state). With a health check, Docker periodically runs a command inside the container; if it fails repeatedly, Docker marks the container as unhealthy.

10) Inspect health status

Run the container in detached mode so it stays in the background:

docker run -d -p 8080:8080 --name simple-api simple-api:1.0

Check status:

docker ps

You should see a (healthy) status after a short time. To see details:

docker inspect --format='{{json .State.Health}}' simple-api

Stop and remove the container when done:

docker rm -f simple-api

Logging: Make Container Logs Useful

In containerized environments, the simplest and most compatible approach is to write logs to standard output. Avoid writing logs to files inside the container unless you have a specific reason and a plan to collect them.

With Flask’s built-in server, you will see request logs in the container output. You can view them with:

docker logs -f simple-api

If you want more structured logs, you can print JSON lines yourself. For example, inside an endpoint you could log a JSON object with the request path and timestamp. The important part for containerization is that logs go to stdout/stderr and are not buffered.

Hardening the Runtime: Common Container API Pitfalls

Binding to localhost inside the container

If your API binds to 127.0.0.1 inside the container, it will not be reachable from the host even if you publish ports. Always bind to 0.0.0.0 for containerized services.

Debug mode in production

Many frameworks have a debug mode that enables auto-reload and verbose errors. Treat debug mode as a development-only setting and control it via an environment variable like DEBUG. In the example, debug is off by default and must be explicitly enabled.

Relying on container IP addresses

Even in local setups, container IPs can change. Prefer stable access patterns such as published ports for host access, and service discovery mechanisms in orchestrated environments. For this project, you access the API via localhost and the published port.

Mixing build-time and run-time configuration

A common mistake is rebuilding the image for every configuration change. Instead, keep the image stable and inject configuration at runtime. Rebuild only when code or dependencies change.

Step-by-Step Validation Checklist

Use this checklist to confirm your containerized API behaves correctly:

  • Build succeeds and produces an image tag you can run.
  • Container starts and stays running without crashing.
  • curl http://localhost:8080/health returns {"status":"ok"}.
  • Changing APP_ENV changes the response from / or /config after restarting the container.
  • Logs are visible via docker logs and appear immediately (not delayed).
  • Health status becomes healthy when running detached.

Practice Tasks: Extend the Project with Realistic Configuration

Task 1: Add CORS configuration

Many APIs need Cross-Origin Resource Sharing settings. Add an environment variable like ALLOWED_ORIGINS and implement a simple check. For learning purposes, you can return the configured origins in /config and later enforce it in responses.

Task 2: Add a feature flag

Add FEATURE_GREETING=true/false. If enabled, the root endpoint returns an extra field like "greeting":"Welcome". This demonstrates how a single image can behave differently in different environments without rebuilds.

Task 3: Make a required secret mandatory

Uncomment the API_KEY check and require it at startup. Run the container without it to see the container exit immediately, then run with -e API_KEY=... to confirm it starts. This teaches you how containers fail when required configuration is missing and why startup validation matters.

Now answer the exercise about the content:

When changing an APIs environment-specific settings in a container (such as APP_ENV or LOG_LEVEL), what is the recommended way to apply the change without modifying the image?

You are right! Congratulations, now go to the next page

You missed! Try again.

Keep the image stable and inject configuration at runtime using environment variables or an env file. To apply changes, restart the container with the new values; rebuild only when code or dependencies change.

Next chapter

Project: API and Database with Multi-Container Orchestration

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.