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 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:
PORTcontrols the listening port inside the container.APP_ENVcan switch behavior (for example, enabling debug logging in development).- Binding to
0.0.0.0is required so Docker can publish the port to your host. - A
/healthendpoint gives a simple way to check container readiness from outside.
3) Add dependencies (requirements.txt)
flask==3.0.34) 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.mdNote: 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=1ensures logs appear immediately in container logs.- A non-root user reduces risk if the process is compromised.
- The health check uses Python instead of
curlto 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.0Test it from your host:
curl http://localhost:8080/
curl http://localhost:8080/health
curl http://localhost:8080/uptime8) 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.0Verify 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.0Test again:
curl http://localhost:8080/healthImportant: 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=8080Run with:
docker run --rm -p 8080:8080 --name simple-api --env-file local.env simple-api:1.0This 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/configHealth 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.0Check status:
docker psYou should see a (healthy) status after a short time. To see details:
docker inspect --format='{{json .State.Health}}' simple-apiStop and remove the container when done:
docker rm -f simple-apiLogging: 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-apiIf 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/healthreturns{"status":"ok"}.- Changing
APP_ENVchanges the response from/or/configafter restarting the container. - Logs are visible via
docker logsand appear immediately (not delayed). - Health status becomes
healthywhen 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.