Home Blog CV Projects Patterns Notes Book Colophon Search

Traefik with Docker Compose and FastAPI

22 May, 2025

This is a basic Traefik and docker compose setup designed for production with Lets Encrypt, Prometheus (linked to Traefik), PostgreSQL, NGINX (for static files only) and FastAPI. There are scripts for setting up automatic updates, firewall and fail2ban for basic hardening.

You'll need to update the scripts with your own passwords, emails and domains.

Let's get started:

cat << 'EOINSTALL' > install.sh 
#!/usr/bin/env bash
set -euo pipefail

# 1) Install prerequisites
apt-get update
apt-get install -y ca-certificates curl gnupg lsb-release

# 2) Add Docker’s official GPG key and repo
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg \
  | gpg --dearmor -o /etc/apt/keyrings/docker.gpg

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/debian \
  $(lsb_release -cs) stable" \
  > /etc/apt/sources.list.d/docker.list

# 3) Install Docker Engine & Compose plugin
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin


mkdir -p static
cat << 'EOF' > static/index.html
Hello!
EOF

cat << 'EOF' > prometheus.yml
scrape_configs:
  - job_name: 'traefik'
    static_configs:
      - targets: ['traefik:9100']
EOF

# 7) Create a starter .env (edit as needed)
# Install if missing:
sudo apt install apache2-utils
# Replace 'admin' and 'yourpassword' with your desired username/pass
CREDS=`htpasswd -nb admin yourpassword`

cat > .env <<EOF
# Replace these with your real values
DOMAIN=innovation.2.example.com
LETSENCRYPT_EMAIL=james@example.com

# In dev you can set:
# DOMAIN=localhost
# DASHBOARD_INSECURE="--api.insecure=true"
DASHBOARD_INSECURE=false
# .env
LETSENCRYPT_CA_SERVER=https://acme-v02.api.letsencrypt.org/directory   # prod
# LETSENCRYPT_CA_SERVER=https://acme-staging-v02.api.letsencrypt.org/directory  # staging
CREDS='${CREDS}'
POSTGRES_USER=appuser
POSTGRES_PASSWORD=supersecure
POSTGRES_DB=appdb
EOF


# 8) Fire it up
docker compose pull
docker compose up -d

echo "✅ Traefik stack launched. Make sure DNS for domain → this host’s IP (ports 80 & 443 open)."
EOINSTALL
cat << 'EOF' > updates.sh 
#!/bin/bash
set -e

# Variables
EMAIL="james@example.com"

# Install unattended-upgrades
echo "Installing unattended-upgrades..."
apt update
apt install -y unattended-upgrades apt-listchanges

# Enable the unattended-upgrades service
echo "Enabling unattended-upgrades..."
dpkg-reconfigure --priority=low unattended-upgrades --frontend=noninteractive

# Configure automatic updates
echo "Configuring unattended-upgrades..."

cat > /etc/apt/apt.conf.d/50unattended-upgrades <<EOL
Unattended-Upgrade::Origins-Pattern {
        "o=Debian,n=bullseye";
        "o=Debian,n=bullseye-updates";
        "o=Debian,n=bullseye-security";
        "o=Debian,n=bullseye-backports";
};
Unattended-Upgrade::Package-Blacklist {
};
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";
Unattended-Upgrade::Mail "$EMAIL";
Unattended-Upgrade::MailOnlyOnError "true";
EOL

# Configure periodic updates
cat > /etc/apt/apt.conf.d/20auto-upgrades <<EOL
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOL

# Enable and restart the unattended-upgrades service
systemctl enable --now unattended-upgrades


echo "Simulating an upgrade ..."
sudo unattended-upgrades --dry-run --debug
echo "done."

echo "Unattended upgrades setup completed. Updates will be applied automatically, and email notifications will be sent to $EMAIL."
admin@ip-172-26-2-246:~$ cat secure.sh 
#!/bin/bash
set -e


# Update and Install Required Security Packages
apt update && apt upgrade -y
apt install -y fail2ban ufw apache2-utils

# Configure UFW
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow http
ufw allow https
ufw enable

# Fail2Ban Configuration
cat > /etc/fail2ban/jail.local <<EOL
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
destemail = james@example.com
sender = james@example.com
mta = sendmail

[sshd]
enabled = true
EOL

systemctl restart fail2ban ufw

echo "Security configuration completed successfully."

Docker compose file:

cat << 'EOF' > compose.yml
services:
  traefik:
    image: traefik:v3.4
    restart: always
    command:
      - "--global.sendAnonymousUsage=false"
      # Metrics
      - "--metrics.prometheus=true"
      - "--entryPoints.metrics.address=:9100"
      - "--metrics.prometheus.entryPoint=metrics"

      # static provider
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"

      # entrypoints
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--entryPoints.web.transport.lifeCycle.graceTimeOut=1"
      - "--entryPoints.websecure.transport.lifeCycle.graceTimeOut=1"
      - "--entryPoints.metrics.transport.lifeCycle.graceTimeOut=1"

      # ACME via HTTP challenge
      - "--certificatesresolvers.le.acme.httpchallenge=true"
      - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
      - "--certificatesresolvers.le.acme.email=${LETSENCRYPT_EMAIL}"
      - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
      # always present, defaulting to prod; override in .env when you want staging
      - "--certificatesresolvers.le.acme.caServer=${LETSENCRYPT_CA_SERVER:-https://acme-v02.api.letsencrypt.org/directory}"

      # logging
      - "--log.level=INFO"
      - "--accesslog=true"

      # enable only in dev
      - "--api.dashboard=true"
      - "${DASHBOARD_INSECURE:-false}"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik_letsencrypt:/letsencrypt


  nginx-static:
    image: nginx:alpine
    restart: always
    volumes:
      - ./static:/usr/share/nginx/html:ro
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.static.rule=Host(`${DOMAIN}`)"
      - "traefik.http.routers.static.entrypoints=websecure"
      - "traefik.http.routers.static.tls.certresolver=le"
      - "traefik.http.services.static.loadbalancer.server.port=80"


  # oauth-provider:
  #   build: ./provider
  #   restart: always
  #   labels:
  #     - "traefik.enable=true"
  #     - "traefik.http.routers.oauth-provider.rule=Host(`auth.${DOMAIN}`) && PathPrefix(`/`)"
  #     - "traefik.http.routers.oauth-provider.tls.certresolver=le"
  #     - "traefik.http.services.oauth-provider.loadbalancer.server.port=10000"

  # oauth-client:
  #   build: ./client
  #   restart: always
  #   labels:
  #     - "traefik.enable=true"
  #     - "traefik.http.routers.oauth-client.rule=Host(`api.${DOMAIN}`) && PathPrefix(`/`)"
  #     - "traefik.http.routers.oauth-client.tls.certresolver=le"
  #     - "traefik.http.services.oauth-client.loadbalancer.server.port=11000"

  prometheus:
    image: prom/prometheus:latest
    command:
      - "--web.external-url=http://localhost/prometheus"
    volumes:
      - ./prometheus.yml:/prometheus/prometheus.yml:ro
    # ports:
    #   - "9090:9090"
    restart: always
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.prometheus.rule=Host(`prometheus.${DOMAIN}`)"
      - "traefik.http.routers.prometheus.entrypoints=websecure"
      - "traefik.http.routers.prometheus.tls.certresolver=le"
      - "traefik.http.services.prometheus.loadbalancer.server.port=9090"
      - "traefik.http.middlewares.prometheus-auth.basicauth.users=${CREDS}"
      - "traefik.http.routers.prometheus.middlewares=prometheus-auth"

  postgres:
    image: postgres:16
    restart: always
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB:-appdb}
    volumes:
      - pg_data:/var/lib/postgresql/data
    # ports:
    #   - "5432:5432"

  fastapi:
    build:
      context: ./fastapi
      dockerfile: Dockerfile
    restart: always
    depends_on:
      - postgres
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.fastapi.rule=Host(`api.${DOMAIN}`)"
      - "traefik.http.routers.fastapi.entrypoints=websecure"
      - "traefik.http.routers.fastapi.tls.certresolver=le"
      - "traefik.http.services.fastapi.loadbalancer.server.port=8000"


volumes:
  pg_data:
  traefik_letsencrypt:

EOF
$ cat fastapi/Dockerfile fastapi/app/main.py 
FROM python:3.12-slim-bookworm

# Install pip & FastAPI + Uvicorn
RUN pip install --no-cache-dir fastapi uvicorn asyncpg

WORKDIR /app

COPY ./app /app

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

class MessageResponse(BaseModel):
    message: str

app = FastAPI()

origins=["https://innovation.2.example.com", "http://localhost:5173"]
app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=False,
    allow_methods=["*"],
    allow_headers=["*"],
    expose_headers=["*"],
)

@app.get(
    "/",
    response_model=MessageResponse,
    summary="Health check",
    tags=["Health"],
    description="Returns a welcome message proving the API is up and CORS is enabled."
)
def read_root():
    return {"message": "Hello from FastAPI with CORS enabled"}

Launch with:

docker compose build fastapi && docker compose down fastapi && docker compose up -d --no-deps fastapi

Then test CORS with:

curl -i -X OPTIONS https://api.innovation.2.example.com/ \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: POST"
HTTP/2 200 
access-control-allow-methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT
access-control-allow-origin: http://localhost:5173
access-control-max-age: 600
content-type: text/plain; charset=utf-8
date: Thu, 22 May 2025 14:51:41 GMT
server: uvicorn
vary: Origin
content-length: 2

OK

And test it:

curl https://api.innovation.2.example.com/
{"message":"Hello from FastAPI with CORS enabled"}

You can get openapi.json with:

curl https://api.innovation.2.example.com/openapi.json
{"openapi":"3.1.0","info":{"title":"FastAPI","version":"0.1.0"},"paths":{"/":{"get":{"tags":["Health"],"summary":"Health check","description":"Returns a welcome message proving the API is up and CORS is enabled.","operationId":"read_root__get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"}}}}}}}},"components":{"schemas":{"MessageResponse":{"properties":{"message":{"type":"string","title":"Message"}},"type":"object","required":["message"],"title":"MessageResponse"}}}}

Comments

Be the first to comment.

Add Comment





Copyright James Gardner 1996-2020 All Rights Reserved. Admin.