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

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:
| Requirement | Description |
| Knowledge | Basic understanding of Docker, FastAPI, React, and Linux |
| Environment | Ubuntu 22.04 LTS (local or EC2 instance) |
| Docker & Docker Compose | Installed and running |
| Git | Installed for version control |
| Domain | Free subdomain from DuckDNS (or any other of your choice) |
| Certbot | For HTTPS setup |
| Grafana & Prometheus | For metrics and observability |
| Loki & Promtail | For log aggregation |
Overview
The project aimed to cover the following milestones:
Containerize both the React frontend and FastAPI backend using Docker.
Orchestrate application services (backend, frontend, database, Adminer, and proxy) using Docker Compose.
Integrate Monitoring and Logging with Prometheus, Grafana, Loki, Promtail, and cAdvisor.
Set up Reverse Proxy using Nginx to route all traffic.
Secure the Deployment using HTTPS and domain mapping.
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-wwwredirectionProper 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











