Skip to main content

Command Palette

Search for a command to run...

Deploying a Full-Stack FastAPI + React App with Docker, Prometheus, Grafana, Loki and Nginx

Updated
12 min read
Deploying a Full-Stack FastAPI + React App with Docker, Prometheus, Grafana, Loki and Nginx
A

DevOps Engineer with 3 years of experience architecting and operating production-grade, cloud-native environments. Specialized in Kubernetes orchestration and Infrastructure as Code (Terraform, Ansible) to deliver "one-click" deployment solutions. Proven track record in implementing GitOps (Argo CD) and full-stack observability (Prometheus/Grafana), ensuring high availability and system transparency from the networking layer to the application.

By Ayobami Agboola — 9/10/2025

This article walks through deploying a full-stack application (FastAPI backend + React frontend) using Docker Compose, adding a monitoring stack (Prometheus, Grafana, Loki/Promtail, cAdvisor), and configuring an HTTPS reverse proxy (Nginx). I explain containerization, compose organization (app vs monitoring stacks), log shipping to Loki, Grafana dashboards, and how to test and verify everything.

Introduction

This project is part of a portfolio-building challenge, a practical, hands-on DevOps task designed to showcase end-to-end skills in containerization, orchestration, monitoring, and deployment.

I deployed a complete full-stack application built with FastAPI (backend) and React (frontend), fully containerized using Docker. To complete the DevOps lifecycle, I integrated a full monitoring and logging stack with Prometheus, Grafana, Loki, Promtail, and cAdvisor, then configured Nginx as a reverse proxy, routing traffic and enforcing HTTPS via Certbot.
Finally, the stack was deployed on an AWS EC2 Ubuntu server, making it publicly accessible with observability dashboards.

Prerequisites

Before you begin, make sure you have the following:

RequirementDescription
KnowledgeBasic understanding of Docker, FastAPI, React, and Linux
EnvironmentUbuntu 22.04 LTS (local or EC2 instance)
Docker & Docker ComposeInstalled and running
GitInstalled for version control
DomainFree subdomain from DuckDNS (or any other of your choice)
CertbotFor HTTPS setup
Grafana & PrometheusFor metrics and observability
Loki & PromtailFor log aggregation

Overview

The project aimed to cover the following milestones:

  1. Containerize both the React frontend and FastAPI backend using Docker.

  2. Orchestrate application services (backend, frontend, database, Adminer, and proxy) using Docker Compose.

  3. Integrate Monitoring and Logging with Prometheus, Grafana, Loki, Promtail, and cAdvisor.

  4. Set up Reverse Proxy using Nginx to route all traffic.

  5. Secure the Deployment using HTTPS and domain mapping.

  6. Deploy to Cloud (AWS EC2) for real-world simulation.

1. Project Setup

1 — Clone repo

Start by cloning the repository and navigate into the project directory:

git clone https://github.com/iamay0bami/cv-challenge01-fastapi-react.git
cd cv-challenge01-fastapi-react

Your project structure should look like this:

cv-challenge01-fastapi-react/
│
├── backend/
│   ├── app/
│   ├── Dockerfile
│   ├── poetry.lock
│
├── frontend/
│   ├── src/
│   ├── Dockerfile
│   ├── package.json
│
├── nginx/
│   ├── nginx.conf
│
├── prometheus/
│   ├── prometheus.yml
│ 
|── loki/
|   
│── promtail/
|
├── grafana/
├── docker-compose.yml
├── docker-compose.monitoring.yml
└── README.md

2 — Containerization: Dockerfile (frontend & backend)

Containerize the backend API built with FastAPI.

This is the backend/Dockerfile used in the project:

Backend (FastAPI) — backend/Dockerfile

# backend/Dockerfile
FROM python:3.10-slim

# Install system deps
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl gcc libpq-dev && \
    rm -rf /var/lib/apt/lists/*

# Install Poetry
RUN curl -sSL https://install.python-poetry.org | python3 -

# Ensure poetry is available
ENV PATH="/root/.local/bin:$PATH"

# Set workdir
WORKDIR /app

# Copy pyproject.toml and poetry.lock first (for caching)
COPY pyproject.toml poetry.lock* /app/

# Install dependencies (no virtualenvs)
RUN poetry config virtualenvs.create false \
    && poetry install --no-interaction --no-ansi --no-root
# Copy the backend app code
COPY . /app

# Expose FastAPI port
EXPOSE 8000

# Run FastAPI
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

This Dockerfile creates a minimal Python environment, installs dependencies, and starts the FastAPI server with Uvicorn.

After creating this Dockerfile, build and run the backend image:

Build the backend image:

docker build -t cv-backend ./backend

Verify by visiting http://localhost/docs (local setup) or http://<ec2_public_ip>****/docs or http://<your_domain>****/docs (cloud setup) to confirm the FastAPI Swagger UI loads correctly.

📸 Screenshot suggestion: Swagger UI showing backend API documentation.

Next, containerize the React frontend application.

This is the frontend/Dockerfile:

Frontend (React) — frontend/Dockerfile

# frontend/Dockerfile
FROM node:18-alpine AS build

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Serve with a lightweight web server
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

This utilizes a multi-stage build, where Node.js is used for compiling and building React, and Nginx is employed to serve static files efficiently.

Then build and run the frontend image:

Build the frontend image:

docker build -t cv-frontend ./frontend

Verify by visiting http:/localhost:8000 (local setup) or http://<ec2_public_ip>:8000 or http://<your_domain> (cloud setup) to confirm the FastAPI Swagger UI loads correctly.

2. Application Stack with Docker Compose

With both services containerized, they can be orchestrated alongside the database, Adminer, and Nginx proxy using docker-compose.yml.

Application Stack — docker-compose.yml

This defines the frontend, backend, database, Adminer, and Nginx reverse proxy.

services:
  cv-backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: cv-backend
    expose:
      - "8000:"   
    env_file:
      - ./backend/.env
    depends_on:
      - cv-db
    restart: unless-stopped
    networks:
      - default
      - shared_net

  cv-frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    container_name: cv-frontend
    expose:
      - "80"
    volumes:
      - ./frontend/nginx.default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - cv-backend
    restart: unless-stopped
    networks:
      - default
      - shared_net

  cv-db:
    image: postgres:14
    container_name: cv-db
    environment:
      POSTGRES_USER: <fill_this_in>
      POSTGRES_PASSWORD: <fill_this_in>
      POSTGRES_DB: <fill_this_in>
    volumes:
      - postgres_data:/var/lib/postgresql/data
    restart: unless-stopped
    networks:
      - default

  cv-adminer:
    image: adminer
    container_name: cv-adminer
    ports:
      - "8080:8080"
    depends_on:
      - cv-db
    restart: unless-stopped
    networks:
      - default

  cv-nginx:
    image: nginx:alpine
    container_name: cv-nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - cv-frontend
      - cv-backend
    restart: unless-stopped
    networks:
      - default
      - shared_net

volumes:
  postgres_data:

networks:
  shared_net:
    external: true

This creates a persistent PostgreSQL database volume and exposes Adminer on port 8080.

Once ready, bring up the entire application stack:

docker-compose up -d

Check that everything is running:

docker ps

3. Monitoring Stack Setup

In this project, a robust monitoring and logging stack was built using:

  • Prometheus → metrics collection

  • cAdvisor → container-level metrics

  • Grafana → visualization & dashboards

  • Loki → centralized log storage

  • Promtail → log shipper feeding Loki

Create docker-compose.monitoring.yml

Below is the Monitoring Stack Compose file used in this project:

version: "3.8"

networks:
  monitoring:
    driver: bridge
  shared_net:
    external: true

volumes:
  prometheus_data: {}
  grafana_data: {}
  loki_data: {}

services:
  cv-prometheus:
    image: prom/prometheus:latest
    container_name: cv-prometheus
    command:
      - "--config.file=/etc/prometheus/prometheus.yml"
      - "--storage.tsdb.path=/prometheus"
#      - "--web.listen-address=:9090"
#      - "--web.route-prefix=/"
#      - "--web.external-url=https://ayobamiagboola.duckdns.org/prometheus"
      - "--web.enable-remote-write-receiver"
      - "--web.route-prefix=/"
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus_data:/prometheus
    networks:
      - monitoring
      - shared_net
    restart: unless-stopped

  cv-grafana:
    image: grafana/grafana:latest
    container_name: cv-grafana
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=<set_your_password>
      - GF_PATHS_PROVISIONING=/etc/grafana/provisioning
      - GF_PATHS_DATA=/var/lib/grafana
      - GF_SERVER_ROOT_URL=https://ayobamiagboola.duckdns.org/grafana/    # <- put your Elastic IP or domain here
      - GF_SERVER_SERVE_FROM_SUB_PATH=true
    volumes:
      - grafana_data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
      - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
    networks:
      - monitoring
      - shared_net
    restart: unless-stopped

  cv-loki:
    image: grafana/loki:2.9.0
    container_name: cv-loki
    command: -config.file=/etc/loki/config.yml
    volumes:
      - ./loki/config.yml:/etc/loki/config.yml:ro
      - ./loki:/loki
      - ./loki/wal:/wal
    ports:
      - "3100:3100"
    networks:
      - monitoring
      - shared_net
    restart: unless-stopped

  cv-promtail:
    image: grafana/promtail:2.9.0
    container_name: cv-promtail
    command: -config.file=/etc/promtail/promtail-config.yml
    volumes:
      - ./promtail/promtail-config.yml:/etc/promtail/promtail-config.yml:ro
      - /var/log:/var/log
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - monitoring
      - shared_net
    restart: unless-stopped

  cv-cadvisor:
    image: gcr.io/cadvisor/cadvisor:latest
    container_name: cv-cadvisor
    volumes:
      - /:/rootfs:ro
      - /var/run:/var/run:rw
      - /sys:/sys:ro
      - /var/lib/docker/:/var/lib/docker:ro
    networks:
      - monitoring
      - shared_net
    restart: unless-stopped

Run this stack with:

docker-compose -f docker-compose.monitoring.yml up -d

Check running containers:

docker ps

4. Monitoring and Logging Stack Configuration

Configure Prometheus

Prometheus scrapes metrics from your FastAPI backend and cAdvisor.
This is the prometheus/prometheus.yml:

global:
  scrape_interval: 15s

scrape_configs:
  - job_name: "prometheus"
    static_configs:
      - targets: ["cv-prometheus:9090"]

  - job_name: "cadvisor"
    metrics_path: '/metrics'
    static_configs:
      - targets: ["cv-cadvisor:8080"]

  - job_name: "app_backend"
    metrics_path: /metrics
    static_configs:
      - targets: ["cv-backend:8000"]

Prometheus collects metrics every 15 seconds from itself, the FastAPI backend, and cAdvisor.

Configure Loki

Here’s the loki/config.yml:

auth_enabled: false

server:
  http_listen_port: 3100
  http_listen_address: 0.0.0.0

ingester:
  chunk_idle_period: 5m
  chunk_retain_period: 30s
  max_transfer_retries: 0
  lifecycler:
    ring:
      kvstore:
        store: inmemory
      replication_factor: 1

schema_config:
  configs:
    - from: 2023-01-01
      store: boltdb-shipper
      object_store: filesystem
      schema: v11
      index:
        prefix: index_
        period: 24h

storage_config:
  boltdb_shipper:
    active_index_directory: /loki/index
    cache_location: /loki/cache
    shared_store: filesystem
  filesystem:
    directory: /loki/chunks

limits_config:
  enforce_metric_name: false
  reject_old_samples: true
  reject_old_samples_max_age: 168h
  retention_period: 24h
# compactor settings (needed when using boltdb-shipper + filesystem)
compactor:
  # working directory for compactor state
  working_directory: /loki/compactor
  # use the filesystem shared store (matches your storage_config)
  shared_store: filesystem

Loki stores logs on the local filesystem and ingests them via Promtail.
We’re using boltdb-shipper mode for simple local persistence.

Configure Promtail

This is the promtail/promtail-config.yml:

server:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://cv-loki:3100/loki/api/v1/push

scrape_configs:
  - job_name: docker-logs
    static_configs:
      - targets:
          - localhost
        labels:
          job: docker
          __path__: /var/lib/docker/containers/*/*.log
  - job_name: docker-sd
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
        refresh_interval: 10s

    # Tell promtail which container logfile to read (from the discovered container id)
    relabel_configs:
      # set the scraped file path for each discovered container by its container id
      - source_labels: ['__meta_docker_container_id']
        regex: '(.*)'
        replacement: '/var/lib/docker/containers/$1/$1-json.log'
        target_label: '__path__'

      # attach container name as a label (e.g. "/cv-backend" -> "cv-backend")
      - source_labels: ['__meta_docker_container_name']
        regex: '/?(.*)'
        target_label: 'container'

      # keep container id as its own label as well
      - source_labels: ['__meta_docker_container_id']
        target_label: 'container_id'

Promtail tails logs from system logs and Docker container logs, sending them to Loki.

This can be verified by visiting Grafana → Explore → Loki datasource → Run {job="docker"} query.

Live container logs streaming from Promtail should be seen.

Configure Grafana

Grafana connects to both Prometheus (metrics) and Loki (logs) to visualize everything.

This is the provisioning directory structure:

grafana/
├── provisioning/
│   ├── dashboards/
│   │   └── dashboards.yml
│   └── datasources/
│       └── datasources.yml
└── dashboards/
    └── container_metrics.json
    └── logs_all_containers.json

Grafana Data Source Config —

Grafana’s provisioning file (grafana/provisioning/datasources/datasource.yml) automatically adds Prometheus and Loki as data sources:

grafana/provisioning/datasources/datasources.yml:

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://cv-prometheus:9090
    isDefault: true
    editable: true
    uid: prometheus

  - name: Loki
    type: loki
    access: proxy
    url: http://cv-loki:3100
    editable: true
    uid: loki

Once Grafana is up, open it on http://localhost:3000 (local setup) or **http://<ec2_public_ip>:**3000 or http://<your_domain>/grafana (cloud setup) (GF_USER / <GF_ADMIN_PW>).

Grafana Dashboard Config — grafana/provisioning/dashboards/dashboards.yml:

apiVersion: 1

providers:
  - name: 'custom-dashboards'
    orgId: 1
    folder: ''
    type: file
    disableDeletion: false
    updateIntervalSeconds: 10
    options:
      path: /var/lib/grafana/dashboards

These configs auto-provision Prometheus and Loki as data sources and automatically load dashboards on startup.

Configure cAdvisor

cAdvisor provides CPU, memory, and disk usage metrics for each running container.
It’s already configured in docker-compose.monitoring.yml.

5. Reverse Proxy Configuration with Nginx

The Nginx reverse proxy acts as the single entry point to all your application services, routing frontend requests, backend APIs, monitoring dashboards, and logs, through well-defined paths.

This setup not only simplifies access but also makes it easier to enable HTTPS later on.

HTTPS (DuckDNS + Certbot)

Once the application works locally, we can move to a production-ready Nginx configuration with HTTPS enabled.
We’ll use DuckDNS for a free domain and Certbot for automatic SSL certificate generation.

Install Certbot in Nginx Container (One-Time)

If you’re running this on a cloud host (like your EC2 instance), you can install Certbot via shell:

sudo apt update
sudo apt install certbot python3-certbot-nginx -y

Then, run the certificate generation command (replace domain):

sudo certbot --nginx -d <your_domain> -d www.<your_domain>

This automatically updates your Nginx config with SSL certificates stored in /etc/letsencrypt.

Final Production Nginx Configuration

Thuis is the HTTPS-enabled Nginx configuration

upstream grafana_upstream {
    server cv-grafana:3000;
}

# ----------------------
# Listen on HTTP (80)
# - serve ACME challenge files
# - redirect other requests to canonical HTTPS (non-www)
# ----------------------
server {
    listen 80;
    listen [::]:80;
    server_name ayobamiagboola.duckdns.org www.ayobamiagboola.duckdns.org;

    # Serve certbot challenges from mounted webroot
    # Use alias so the URL maps to files under /var/www/certbot/.well-known/acme-challenge/
    location ^~ /.well-known/acme-challenge/ {
        alias /var/www/certbot/.well-known/acme-challenge/;
        try_files $uri =404;
        access_log off;
        expires 1h;
    }

    # Everything else -> canonical HTTPS non-www
    location / {
        return 301 https://ayobamiagboola.duckdns.org$request_uri;
    }
}

# ----------------------
# HTTPS server (443)
# - All original locations preserved here
# ----------------------
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name ayobamiagboola.duckdns.org www.ayobamiagboola.duckdns.org;

    # TLS certs created by certbot (webroot)
    ssl_certificate /etc/letsencrypt/live/ayobamiagboola.duckdns.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ayobamiagboola.duckdns.org/privkey.pem;
    ssl_session_timeout 1d;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers 'HIGH:!aNULL:!MD5';

    # HSTS - long lifetime (adjust if you need)
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

    # If request came to www, redirect to canonical non-www HTTPS immediately
    if ($host = 'www.ayobamiagboola.duckdns.org') {
        return 301 https://ayobamiagboola.duckdns.org$request_uri;
    }

    # --- Static Assets ---
    location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|map|woff2?|woff|ttf)$ {
        proxy_pass http://cv-frontend:80;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /assets/ {
        proxy_pass http://cv-frontend:80;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # --- Compatibility shim: forward /api/v1/* -> /api/* ---
    location ^~ /api/v1/ {
        rewrite ^/api/v1/(.*)$ /api/$1 break;
        proxy_pass http://cv-backend:8000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # --- Backend API ---
    location /api/ {
        proxy_pass http://cv-backend:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # --- Backend Docs ---
    location /docs {
        proxy_pass http://cv-backend:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
    }

    location = /docs/ {
        proxy_pass http://cv-backend:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
    }

    # Safe alias: allow /api/docs/ to reach real /docs/
    location /api/docs/ {
        proxy_pass http://cv-backend:8000/docs/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # --- Backend Metrics ---
    location /metrics {
        proxy_pass http://cv-backend:8000/metrics;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
    }

    # --- Grafana ---
    location ^~ /grafana/ {
        proxy_pass http://grafana_upstream; # upstream used intentionally (no URI appended)
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Port $server_port;
        proxy_redirect off;
    }
    location = /grafana { return 301 /grafana/; }

    # --- Prometheus ---
    location ^~ /prometheus/ {
        proxy_pass http://cv-prometheus:9090/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Script-Name /prometheus;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_redirect off;
    }
    location = /prometheus { return 301 /prometheus/; }

    # --- cAdvisor ---
    location ^~ /cadvisor/ {
        proxy_pass http://cv-cadvisor:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
    }
    location = /cadvisor { return 301 /cadvisor/; }

    # --- Adminer ---
    location /adminer/ {
        proxy_pass http://cv-adminer:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    location = /adminer { return 302 /adminer/; }

    # --- SPA Fallback ---
    location / {
        proxy_pass http://cv-frontend:80/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

This final setup ensures:

  • HTTP → HTTPS redirection

  • www → non-www redirection

  • Proper routing to all stack components

  • Encrypted traffic via Certbot-generated SSL

6. Final Setup with Screenshots

After setting up all services with http to https redirection and serving the application via AWS EC2 all services are up and running as shown in the images below