Build a Personal Wellness API
Track energy, habits, and routines with a containerized FastAPI backend.
Introduction
30 Second Summary
Production applications rarely run alone. They depend on databases, caches, and background services that all need to start in the right order and talk to each other reliably. Getting this right on every developer's machine is one of the biggest headaches in software engineering.
In this project, you will build a financial data API platform that fetches real stock market data, stores it in a database, caches it for speed, and serves it through an API. The entire 3-service stack runs locally with a single command thanks to Docker Compose.
What You'll Build
A containerized data platform with a FastAPI service, PostgreSQL database, and Redis cache, orchestrated with Docker using production best practices like multi-stage builds and health checks.
By the end of this project, you'll have:
- A multi-stage Dockerfile that builds an optimized Python image with a builder stage and a slim runtime stage.
- A compose.yaml defining 3 services with health checks, dependency ordering, custom networks, and named volumes.
- A working FastAPI application that ingests real stock data from Alpha Vantage and serves it through a cached API layer.
- Secret Mission: Add a background data worker as a 4th container service that periodically ingests data on a schedule.
Are there any prerequisites?
You need basic Python familiarity, Git installed, and VS Code installed. No Docker experience is required. Step 1 walks you through the full Docker Desktop setup.
Install Docker Desktop and Set Up the Project
Your financial data platform needs three services running together: a FastAPI application, a PostgreSQL database, and a Redis cache.
Instead of installing each service manually on your machine, Docker packages them into containers that start with a single command. In this step, you will install Docker Desktop, scaffold your project files, and grab an API key for stock market data.
In this step, get ready to:
- Install Docker Desktop and verify it works.
- Create your project directory with all required files.
- Sign up for a free Alpha Vantage API key.
Download and install Docker Desktop
Docker Desktop is the application that runs containers on your machine. It includes the docker CLI, Docker Compose, and a graphical interface for managing containers.
- Open the Docker Desktop download page for your operating system:
Windows
- Visit the Docker Desktop for Windows install page.
- Click the download button and run the installer.
- The installer uses the WSL 2 backend by default. Accept the defaults and follow the prompts.
- When the installation completes, Docker Desktop launches automatically.
- Accept the Docker Subscription Service Agreement when prompted.
What is WSL 2?
WSL 2 (Windows Subsystem for Linux 2) is a lightweight Linux environment built into Windows. Docker Desktop uses it to run Linux containers natively on your machine without a traditional virtual machine.
macOS
- Visit the Docker Desktop for Mac install page.
- Download the correct installer for your chip (Apple Silicon or Intel).
- Double-click the downloaded Docker.dmg file, then drag the Docker icon to your Applications folder.
- Open Docker.app from your Applications folder to start Docker Desktop.
- Accept the Docker Subscription Service Agreement when prompted. The first launch may take a few minutes.
Is Docker Desktop free?
Docker Desktop is free for personal use, education, and small businesses (fewer than 250 employees AND less than $10 million in annual revenue). This project qualifies for the free tier.
- Once Docker Desktop shows the dashboard (a whale icon appears in your system tray or menu bar), verify the installation by running this command in your terminal:
docker --version
- Also verify Docker Compose is available by running:
docker compose version
Both commands should print a version number.
✔️ I see version numbers for both commands
Docker is ready to go. You can proceed to the next substep.
ⓧ Command not found
- Make sure Docker Desktop is fully running. Look for the whale icon in your system tray (Windows) or menu bar (macOS).
- Close your terminal and open a new one. Docker adds itself to your PATH during installation, but existing terminals may not pick it up.
- On Windows, ensure WSL 2 is enabled. Open PowerShell as Administrator and run wsl --install if needed, then restart your machine.
- Try restarting Docker Desktop from the application menu.
Create the project directory structure
Your project needs a specific folder layout with separate files for the Docker configuration, the Python application code, and environment variables.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir market-data-api
cd market-data-api
- Initialize a Git repository by running:
git init
- Create the application directory and all required files by running these commands:
macOS
mkdir app
touch compose.yaml Dockerfile .dockerignore .env requirements.txt
touch app/__init__.py app/main.py app/database.py app/services.py
Windows
mkdir app
New-Item compose.yaml, Dockerfile, .dockerignore, .env, requirements.txt -ItemType File
New-Item app/__init__.py, app/main.py, app/database.py, app/services.py -ItemType File
What are all these files for?
Each file has a specific role in your containerized application:
- Dockerfile defines how to package your Python app into a container image.
- compose.yaml orchestrates all three services (API, database, cache) together.
- .env stores configuration values like database credentials and API keys.
- .dockerignore tells Docker which files to exclude from the container image.
- requirements.txt lists the Python packages your application needs.
- The app/ directory holds your FastAPI application code split across three modules.
- Confirm all files were created by running:
ls
You should see compose.yaml, Dockerfile, .dockerignore, .env, requirements.txt, and the app folder in the output.
Missing some files?
If .dockerignore or .env are not visible, they may be hidden because their names start with a dot. Run ls -a (macOS) or ls -Force (PowerShell) to show hidden files.
If files are missing entirely, re-run the touch or New-Item commands from above. Make sure you are inside the market-data-api folder before creating them.
Help me troubleshoot missing project files.
Get your Alpha Vantage API key
Alpha Vantage provides free stock market data through a REST API. You need an API key to authenticate your requests when fetching daily price data.
- Open the Alpha Vantage API key signup page in your browser.
- Fill in your name, email, and select Student or another appropriate option for your use case.
- Click GET FREE API KEY.
- Copy the API key that appears on screen. Keep this key safe.
What is the Alpha Vantage free tier?
The free tier allows 25 API requests per day and 5 requests per minute. This is more than enough for this project.
- Open the .env file inside your market-data-api folder in VS Code, then paste the following configuration:
ALPHA_VANTAGE_API_KEY=your-api-key-here
POSTGRES_USER=marketdata
POSTGRES_PASSWORD=marketpass123
POSTGRES_DB=marketdb
REDIS_HOST=cache
REDIS_PORT=6379
DATABASE_HOST=db
DATABASE_PORT=5432
What do these variables do?
ALPHA_VANTAGE_API_KEY authenticates your requests to the stock data API.
The POSTGRES_* variables configure the database container's username, password, and database name. DATABASE_HOST and DATABASE_PORT tell the API how to find the database inside the Docker network.
REDIS_HOST and REDIS_PORT tell the API where to find the cache service. The host names (db and cache) match the service names you will define in compose.yaml later.
- Replace your-api-key-here with the actual Alpha Vantage API key you just copied.
- Save the .env file.
Don't see your API key?
If the Alpha Vantage page did not display a key after submitting the form, check your email for a confirmation message. Some users need to verify their email first.
If you want to test later without a real key, you can temporarily use the value demo which works for the IBM symbol only.
Help me get my Alpha Vantage API key working.
Your development environment is ready. You have Docker running, a clean project structure, and an API key for fetching stock data. Next up, you will write the FastAPI application that ingests and serves financial data.
Write the FastAPI Application
Your project structure is in place and your environment variables are configured. Now you need to write the actual application code that will run inside Docker containers.
In this step, you will build a FastAPI application that ingests stock market data from Alpha Vantage, stores it in PostgreSQL, and caches frequently-requested data in Redis. The app cannot run locally yet because it depends on database and cache services that Docker will provide in the next step.
In this step, get ready to:
- Write pinned Python dependencies in requirements.txt.
- Build the FastAPI app with health, ingest, and query endpoints.
- Write the database and service layers that connect to PostgreSQL and Redis.
Write requirements.txt and set up the FastAPI app
Every Python project needs a list of its dependencies with pinned versions. This ensures Docker installs the exact same packages every time it builds the image.
- Open requirements.txt in VS Code and add the following pinned dependencies:
fastapi==0.138.1
uvicorn==0.49.0
psycopg2-binary==2.9.10
redis==5.3.0
httpx==0.28.1
What do these packages do?
- fastapi is the web framework that handles HTTP requests and routing.
- uvicorn is the ASGI server that runs the FastAPI app.
- psycopg2-binary is the PostgreSQL database driver for Python.
- redis is the Python client for the Redis cache.
- httpx is an HTTP client for calling the Alpha Vantage API.
- Save requirements.txt.
Now you will write the main application file. This file creates the FastAPI app, registers a startup event to initialize the database, and defines the API endpoints.
- Open app/main.py and add the imports, app setup, and health endpoint:
import os
from fastapi import FastAPI, HTTPException
from app.database import get_connection, init_db
from app.services import fetch_stock_data, get_cached_data, set_cached_data
# Create the FastAPI application instance
app = FastAPI(title="Market Data API")
# Initialize the database table when the app starts
@app.on_event("startup")
def startup():
init_db()
# Simple health check endpoint for Docker healthchecks
@app.get("/health")
def health_check():
return {"status": "healthy", "service": "market-data-api"}
What does this code do?
- The @app.on_event("startup") decorator runs init_db() once when the server starts. This creates the database table if it does not already exist.
- The /health endpoint returns a simple JSON response. Docker will use this later to confirm the container is running correctly.
The imports reference app.database and app.services which you will write shortly. Your editor may show errors until those files are filled in.
- You won't see any output yet. The next chunk adds the ingest endpoint to the same file.
The ingest endpoint fetches daily stock data from Alpha Vantage and writes each record into PostgreSQL. It uses an upsert pattern so re-ingesting the same data updates existing rows instead of failing.
- In app/main.py, add the ingest endpoint below the health check function:
@app.post("/ingest/{symbol}")
def ingest_stock_data(symbol: str):
# Normalize the ticker symbol to uppercase
symbol = symbol.upper()
data = fetch_stock_data(symbol)
if not data:
raise HTTPException(status_code=502, detail="Failed to fetch data from Alpha Vantage")
conn = get_connection()
cur = conn.cursor()
# Insert each day's OHLCV data, updating on conflict
records_inserted = 0
for date_str, values in data.items():
cur.execute(
"""
INSERT INTO stock_prices (symbol, date, open, high, low, close, volume)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (symbol, date) DO UPDATE SET
open = EXCLUDED.open,
high = EXCLUDED.high,
low = EXCLUDED.low,
close = EXCLUDED.close,
volume = EXCLUDED.volume
""",
(
symbol,
date_str,
float(values["1. open"]),
float(values["2. high"]),
float(values["3. low"]),
float(values["4. close"]),
int(values["5. volume"]),
),
)
records_inserted += 1
conn.commit()
cur.close()
conn.close()
# Invalidate any cached data for this symbol so the next query fetches fresh data
set_cached_data(symbol, None)
return {"symbol": symbol, "records_inserted": records_inserted}
What does this endpoint do?
- It calls fetch_stock_data(symbol) to pull daily price data from the Alpha Vantage API.
- The ON CONFLICT ... DO UPDATE clause is a PostgreSQL upsert. If data for the same symbol and date already exists, it updates the row instead of raising a duplicate error.
- After inserting, it invalidates the Redis cache for that symbol so the next query reflects the fresh data.
- You won't see any output yet. The next chunk completes app/main.py with the query endpoint.
The stocks endpoint serves cached data when available. If the cache is empty, it queries PostgreSQL and stores the result in Redis for 5 minutes.
- In app/main.py, add the stocks endpoint below the ingest function:
@app.get("/stocks/{symbol}")
def get_stock_data(symbol: str):
symbol = symbol.upper()
# Check the Redis cache first
cached = get_cached_data(symbol)
if cached:
return {"symbol": symbol, "source": "cache", "data": cached}
# If not cached, query PostgreSQL
conn = get_connection()
cur = conn.cursor()
cur.execute(
"""
SELECT date, open, high, low, close, volume
FROM stock_prices
WHERE symbol = %s
ORDER BY date DESC
LIMIT 30
""",
(symbol,),
)
rows = cur.fetchall()
cur.close()
conn.close()
if not rows:
raise HTTPException(status_code=404, detail=f"No data found for {symbol}")
# Format the rows into a list of dictionaries
result = [
{
"date": str(row[0]),
"open": row[1],
"high": row[2],
"low": row[3],
"close": row[4],
"volume": row[5],
}
for row in rows
]
# Cache the result in Redis for future requests
set_cached_data(symbol, result)
return {"symbol": symbol, "source": "database", "data": result}
What does this endpoint do?
- It checks Redis first. If cached data exists, it returns immediately with "source": "cache".
- If the cache is empty, it queries PostgreSQL for the 30 most recent records and returns them with "source": "database".
- After reading from the database, it stores the result in Redis so subsequent requests are served from cache.
- Save app/main.py.
Seeing red squiggly lines in your editor?
Your editor may show import errors for app.database and app.services because those files are still empty. This is expected. The errors will disappear once you write those files in the next substep.
If you see a syntax error highlighted (a red underline on your own code, not the imports), check for missing commas or unmatched parentheses in the cur.execute() calls.
Help me find a syntax error in my app/main.py file.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/main.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete app/main.py file:
import os
from fastapi import FastAPI, HTTPException
from app.database import get_connection, init_db
from app.services import fetch_stock_data, get_cached_data, set_cached_data
app = FastAPI(title="Market Data API")
@app.on_event("startup")
def startup():
init_db()
@app.get("/health")
def health_check():
return {"status": "healthy", "service": "market-data-api"}
@app.post("/ingest/{symbol}")
def ingest_stock_data(symbol: str):
symbol = symbol.upper()
data = fetch_stock_data(symbol)
if not data:
raise HTTPException(status_code=502, detail="Failed to fetch data from Alpha Vantage")
conn = get_connection()
cur = conn.cursor()
records_inserted = 0
for date_str, values in data.items():
cur.execute(
"""
INSERT INTO stock_prices (symbol, date, open, high, low, close, volume)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (symbol, date) DO UPDATE SET
open = EXCLUDED.open,
high = EXCLUDED.high,
low = EXCLUDED.low,
close = EXCLUDED.close,
volume = EXCLUDED.volume
""",
(
symbol,
date_str,
float(values["1. open"]),
float(values["2. high"]),
float(values["3. low"]),
float(values["4. close"]),
int(values["5. volume"]),
),
)
records_inserted += 1
conn.commit()
cur.close()
conn.close()
set_cached_data(symbol, None)
return {"symbol": symbol, "records_inserted": records_inserted}
@app.get("/stocks/{symbol}")
def get_stock_data(symbol: str):
symbol = symbol.upper()
cached = get_cached_data(symbol)
if cached:
return {"symbol": symbol, "source": "cache", "data": cached}
conn = get_connection()
cur = conn.cursor()
cur.execute(
"""
SELECT date, open, high, low, close, volume
FROM stock_prices
WHERE symbol = %s
ORDER BY date DESC
LIMIT 30
""",
(symbol,),
)
rows = cur.fetchall()
cur.close()
conn.close()
if not rows:
raise HTTPException(status_code=404, detail=f"No data found for {symbol}")
result = [
{
"date": str(row[0]),
"open": row[1],
"high": row[2],
"low": row[3],
"close": row[4],
"volume": row[5],
}
for row in rows
]
set_cached_data(symbol, result)
return {"symbol": symbol, "source": "database", "data": result}
Write the database and service layers
The API endpoints you just wrote depend on two helper modules. The database module handles PostgreSQL connections and table creation. The services module handles external API calls and Redis caching.
- Open app/database.py and add the connection and initialization logic:
import os
import psycopg2
# Create a connection to PostgreSQL using environment variables
def get_connection():
return psycopg2.connect(
host=os.environ.get("DATABASE_HOST", "db"),
port=os.environ.get("DATABASE_PORT", "5432"),
user=os.environ.get("POSTGRES_USER", "marketdata"),
password=os.environ.get("POSTGRES_PASSWORD", "marketpass123"),
dbname=os.environ.get("POSTGRES_DB", "marketdb"),
)
# Create the stock_prices table if it does not exist
def init_db():
conn = get_connection()
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS stock_prices (
id SERIAL PRIMARY KEY,
symbol VARCHAR(10) NOT NULL,
date DATE NOT NULL,
open NUMERIC(12, 4),
high NUMERIC(12, 4),
low NUMERIC(12, 4),
close NUMERIC(12, 4),
volume BIGINT,
UNIQUE(symbol, date)
)
"""
)
conn.commit()
cur.close()
conn.close()
What does this code do?
- get_connection() reads database credentials from environment variables (set in your .env file). The fallback values match what you configured in Step 1.
- init_db() creates a stock_prices table with columns for the ticker symbol, date, OHLCV data (open, high, low, close, volume), and a unique constraint on (symbol, date) to prevent duplicate rows.
The UNIQUE(symbol, date) constraint is what makes the upsert pattern work. When you insert a row that would violate this constraint, PostgreSQL triggers the ON CONFLICT clause instead of raising an error.
- Save app/database.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/database.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete app/database.py file:
import os
import psycopg2
def get_connection():
return psycopg2.connect(
host=os.environ.get("DATABASE_HOST", "db"),
port=os.environ.get("DATABASE_PORT", "5432"),
user=os.environ.get("POSTGRES_USER", "marketdata"),
password=os.environ.get("POSTGRES_PASSWORD", "marketpass123"),
dbname=os.environ.get("POSTGRES_DB", "marketdb"),
)
def init_db():
conn = get_connection()
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS stock_prices (
id SERIAL PRIMARY KEY,
symbol VARCHAR(10) NOT NULL,
date DATE NOT NULL,
open NUMERIC(12, 4),
high NUMERIC(12, 4),
low NUMERIC(12, 4),
close NUMERIC(12, 4),
volume BIGINT,
UNIQUE(symbol, date)
)
"""
)
conn.commit()
cur.close()
conn.close()
Now you will write the services module. This handles two responsibilities: fetching stock data from the Alpha Vantage API and reading/writing the Redis cache.
- Open app/services.py and add the imports and the Alpha Vantage client:
import os
import json
import httpx
import redis
# Create a Redis client using environment variables
def get_redis_client():
return redis.Redis(
host=os.environ.get("REDIS_HOST", "cache"),
port=int(os.environ.get("REDIS_PORT", "6379")),
decode_responses=True,
)
# Fetch daily stock data from Alpha Vantage
def fetch_stock_data(symbol: str) -> dict | None:
api_key = os.environ.get("ALPHA_VANTAGE_API_KEY", "demo")
url = "https://www.alphavantage.co/query"
params = {
"function": "TIME_SERIES_DAILY",
"symbol": symbol,
"apikey": api_key,
"outputsize": "compact",
}
response = httpx.get(url, params=params, timeout=30.0)
if response.status_code != 200:
return None
data = response.json()
time_series = data.get("Time Series (Daily)")
return time_series
What does this code do?
- get_redis_client() creates a Redis connection. The hostname cache matches the service name you will define in compose.yaml later. Docker networking resolves service names to container IPs automatically.
- fetch_stock_data() calls the Alpha Vantage TIME_SERIES_DAILY endpoint and returns the daily price data as a dictionary keyed by date.
- The "compact" output size returns the most recent 100 data points, which keeps API calls lightweight.
- You won't see any output yet. The next chunk adds the caching functions to complete this file.
The caching layer reads and writes stock data in Redis. Cached entries expire after 5 minutes so the data stays relatively fresh without hitting the database on every request.
- In app/services.py, add the caching functions below the fetch_stock_data function:
# Read cached stock data from Redis
def get_cached_data(symbol: str) -> list | None:
try:
client = get_redis_client()
cached = client.get(f"stock:{symbol}")
if cached:
return json.loads(cached)
except redis.ConnectionError:
pass
return None
# Write stock data to Redis cache (or delete if data is None)
def set_cached_data(symbol: str, data: list | None):
try:
client = get_redis_client()
if data is None:
client.delete(f"stock:{symbol}")
else:
client.set(f"stock:{symbol}", json.dumps(data), ex=300)
except redis.ConnectionError:
pass
What does this code do?
- get_cached_data() looks up stock:{symbol} in Redis. If it finds a value, it deserializes the JSON string back into a Python list.
- set_cached_data() either stores data with a 300-second (5-minute) expiry, or deletes the key if data is None (used to invalidate stale cache after ingestion).
- Both functions wrap Redis calls in a try/except block. If Redis is down, the app gracefully falls back to the database instead of crashing.
- Save app/services.py.
Still seeing import errors in app/main.py?
Make sure you saved both app/database.py and app/services.py. Your editor resolves imports from saved files on disk, not from unsaved buffers.
Also confirm that app/__init__.py exists (it can be empty). This file tells Python that the app/ directory is a package.
Help me fix import errors in my FastAPI project.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/services.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete app/services.py file:
import os
import json
import httpx
import redis
def get_redis_client():
return redis.Redis(
host=os.environ.get("REDIS_HOST", "cache"),
port=int(os.environ.get("REDIS_PORT", "6379")),
decode_responses=True,
)
def fetch_stock_data(symbol: str) -> dict | None:
api_key = os.environ.get("ALPHA_VANTAGE_API_KEY", "demo")
url = "https://www.alphavantage.co/query"
params = {
"function": "TIME_SERIES_DAILY",
"symbol": symbol,
"apikey": api_key,
"outputsize": "compact",
}
response = httpx.get(url, params=params, timeout=30.0)
if response.status_code != 200:
return None
data = response.json()
time_series = data.get("Time Series (Daily)")
return time_series
def get_cached_data(symbol: str) -> list | None:
try:
client = get_redis_client()
cached = client.get(f"stock:{symbol}")
if cached:
return json.loads(cached)
except redis.ConnectionError:
pass
return None
def set_cached_data(symbol: str, data: list | None):
try:
client = get_redis_client()
if data is None:
client.delete(f"stock:{symbol}")
else:
client.set(f"stock:{symbol}", json.dumps(data), ex=300)
except redis.ConnectionError:
pass
Review the code for syntax correctness
You now have all four application files written. The app cannot run locally because it depends on PostgreSQL and Redis services that do not exist on your machine yet.
This is exactly the point of Docker. In the next step, you will build a container image that packages this code with its dependencies. Docker Compose will then spin up PostgreSQL and Redis alongside your API so everything connects automatically.
- Confirm your files have no syntax highlighting errors (red underlines on your own code). Import resolution warnings for app.database and app.services are fine as long as the files are saved.
- Verify that app/__init__.py exists (it should be empty). This makes the app/ directory importable as a Python package.
Why can't you run this locally?
Running uvicorn app.main:app would fail immediately because init_db() tries to connect to PostgreSQL at hostname db on port 5432. That hostname only exists inside the Docker network you will create next.
This is the core value of Docker. Instead of installing PostgreSQL and Redis on your local machine, Docker provides them as isolated containers on a shared network.
Your application code is ready. Next up, you will write a multi-stage Dockerfile that packages this code into a lean production image.
Build a Multi-Stage Dockerfile
In the previous step, you wrote all three Python files for your FastAPI application. However, the app depends on PostgreSQL and Redis, which aren't installed on your machine.
A multi-stage Dockerfile solves this by packaging your app into an optimized image that runs anywhere Docker is installed. The first stage handles the heavy lifting (installing packages), and the second stage keeps only what's needed at runtime.
In this step, get ready to:
- Write a builder stage that installs dependencies into a virtual environment.
- Write a runtime stage that creates a lean, secure production image.
- Build the Docker image and verify its size is optimized.
Write the builder stage
The builder stage's job is to install all Python packages into an isolated virtual environment. Everything in this stage (pip cache, build headers, temporary files) stays behind when Docker moves to the next stage.
- Open Dockerfile in VS Code and paste the following code:
# syntax=docker/dockerfile:1
# Stage 1: Builder - install dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN python -m venv /opt/venv && \
/opt/venv/bin/pip install --no-cache-dir -r requirements.txt
What does this code do?
- # syntax=docker/dockerfile:1 enables the latest Dockerfile syntax features.
- FROM python:3.12-slim AS builder starts a named build stage using the official slim Python image.
- WORKDIR /app sets the working directory inside the container.
- COPY requirements.txt . brings only the requirements file into the stage, not the full app code.
- RUN python -m venv /opt/venv creates a virtual environment, then installs all packages there. The --no-cache-dir flag prevents pip from storing download caches in the image.
- Save Dockerfile.
You won't see a change yet. The next substep adds the runtime stage that uses these installed packages.
Write the runtime stage and .dockerignore
The runtime stage starts from a fresh python:3.12-slim image. It copies only the virtual environment from the builder stage, discarding all build artifacts.
- In Dockerfile, add the following code below the builder stage:
# Stage 2: Runtime - lean production image
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN useradd --create-home appuser
USER appuser
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
What does this code do?
- FROM python:3.12-slim AS runtime starts a brand-new image with no leftover build artifacts.
- COPY --from=builder /opt/venv /opt/venv pulls the virtual environment from the builder stage into this clean image.
- ENV PATH="/opt/venv/bin:$PATH" makes installed packages available without specifying the full path.
- RUN useradd --create-home appuser and USER appuser create and switch to a non-root user for security.
- COPY app/ ./app/ copies your application code into the final image.
- EXPOSE 8000 documents which port the container listens on.
- CMD [...] defines the default command that starts your FastAPI server with uvicorn.
Why multi-stage builds?
A single-stage Dockerfile would include pip caches, build tools, and temporary files in the final image. Multi-stage builds let you install everything in one stage and copy only the results to a second, clean stage.
This is the same pattern used in ECS and Kubernetes deployments, where smaller images mean faster pulls and quicker cold starts.
- Save Dockerfile.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
# syntax=docker/dockerfile:1
# Stage 1: Builder - install dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN python -m venv /opt/venv && \
/opt/venv/bin/pip install --no-cache-dir -r requirements.txt
# Stage 2: Runtime - lean production image
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN useradd --create-home appuser
USER appuser
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
A .dockerignore file tells Docker which files to skip when copying your project into the image. Without it, Docker sends everything (including .git history and .env secrets) into the build context.
- Open .dockerignore in VS Code and paste the following:
.git
__pycache__
*.pyc
.env
.venv
*.egg-info
.pytest_cache
What does this file do?
- .git excludes your entire Git history from the build context.
- __pycache__ and *.pyc exclude compiled Python bytecode.
- .env keeps your secrets out of the Docker image.
- .venv excludes any local virtual environment.
- *.egg-info and .pytest_cache exclude development artifacts.
- Save .dockerignore.
You won't see a visible change yet. The next substep builds the image and confirms everything works together.
Build the image and check its size
Time to turn your Dockerfile into an actual image. The docker build command processes each instruction and produces a reusable image you can run as a container.
- Build your Docker image by running this command in the terminal from your market-data-api folder:
docker build -t market-api .
What does this command do?
- docker build reads the Dockerfile and executes each instruction in order.
- -t market-api names (tags) the resulting image so you can reference it later.
- . tells Docker to use the current directory as the build context.
The build will take a minute or two while Docker downloads the base image and installs your Python packages.
- After the build completes, check the image size by running:
docker images
- Look for market-api in the output. A well-optimized Python image should be under 200MB.
Seeing an error during the build?
- Make sure Docker Desktop is running. You should see the Docker whale icon in your system tray or menu bar.
- Check that requirements.txt is in the same folder as Dockerfile (not inside the app/ directory).
- Verify .dockerignore does not accidentally exclude requirements.txt or the app/ directory.
- Why is my Docker build failing?
Your API image is built and optimized. Next up, you'll orchestrate it alongside PostgreSQL and Redis using Docker Compose.
Orchestrate with Docker Compose
You have a fully built Docker image for your API. The problem is that your application needs two other services to function: a PostgreSQL database and a Redis cache.
Docker Compose lets you define and run all three services together with a single command. In this step, you will write a compose.yaml file that orchestrates the entire stack with health checks, dependency ordering, and a custom network.
In this step, get ready to:
- Define PostgreSQL and Redis services with health checks.
- Add the API service with dependency ordering and environment variables.
- Launch the full 3-service stack and verify everything is healthy.
Define the database and cache services
Your API depends on PostgreSQL for storage and Redis for caching. Both need to be fully healthy before the API starts, so you will define health checks that Docker monitors automatically.
- Open compose.yaml in VS Code (it should be empty from Step 1).
- Paste the following infrastructure service definitions:
services:
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
expose:
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- market-net
restart: unless-stopped
cache:
image: redis:8-alpine
expose:
- "6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
networks:
- market-net
restart: unless-stopped
networks:
market-net:
driver: bridge
volumes:
pgdata:
What does this code do?
The db service pulls the postgres:16-alpine image, mounts a named volume (pgdata) so data persists across container restarts, and uses pg_isready to report its health to Docker.
The cache service pulls redis:8-alpine and uses redis-cli ping as its health check. Both services join a custom bridge network called market-net so containers can find each other by service name.
The expose keyword opens ports only to other containers on the same network. It does NOT publish them to your host machine.
What is a named volume?
A named volume is Docker-managed storage that lives outside the container's filesystem. When you stop or remove the db container, the pgdata volume keeps your database intact. Next time you start the container, all your data is still there.
- Save compose.yaml.
Seeing YAML indentation errors?
YAML files are sensitive to spaces. Make sure you are using spaces (not tabs) and that each level is indented by exactly 2 spaces. Check that services: starts at column 0 with no leading whitespace.
help me fix YAML indentation issues in my compose.yaml
Add the API service with dependencies
The API service builds from your Dockerfile and needs to wait until both the database and cache are healthy before starting. Docker Compose's depends_on with condition: service_healthy enforces this startup ordering automatically.
- In compose.yaml, find the line that says services: at the very top.
- Add the following api service block directly below services: and above the db: line:
api:
build:
context: .
ports:
- "8000:8000"
environment:
- ALPHA_VANTAGE_API_KEY=${ALPHA_VANTAGE_API_KEY}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- DATABASE_HOST=db
- DATABASE_PORT=5432
- REDIS_HOST=cache
- REDIS_PORT=6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
networks:
- market-net
restart: unless-stopped
What does this code do?
build: context: . tells Docker Compose to build the api image from the Dockerfile in the current directory.
ports: "8000:8000" maps port 8000 on your machine to port 8000 inside the container. This is how you reach the API from your browser.
The environment block passes values from your .env file into the container at runtime. The ${VARIABLE} syntax means Docker Compose reads the value from .env automatically.
depends_on with condition: service_healthy prevents the API from starting until both db and cache report healthy via their health checks.
Why depends_on instead of just starting everything?
Without dependency ordering, all three containers launch simultaneously. Your API would try to connect to PostgreSQL before the database is ready, causing a crash. Health-check-based ordering means Docker waits for a confirmed healthy signal before starting the next service.
- Save compose.yaml.
- Confirm that api: sits directly under services: and above db:, with the same 2-space indentation.
Docker Compose complains about invalid syntax?
Make sure the api: block is at the same indent level as db: and cache: (2 spaces in from services:). Also check that there is a blank line between the api block and the db block.
help me debug my compose.yaml syntax error
✔️ Awesome, I've got everything!
Great. Make sure you have saved compose.yaml before moving on.
ⓧ I'd like to double check the full code
services:
api:
build:
context: .
ports:
- "8000:8000"
environment:
- ALPHA_VANTAGE_API_KEY=${ALPHA_VANTAGE_API_KEY}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- DATABASE_HOST=db
- DATABASE_PORT=5432
- REDIS_HOST=cache
- REDIS_PORT=6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
networks:
- market-net
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
expose:
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- market-net
restart: unless-stopped
cache:
image: redis:8-alpine
expose:
- "6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
networks:
- market-net
restart: unless-stopped
networks:
market-net:
driver: bridge
volumes:
pgdata:
Launch the stack and verify all services are healthy
With the Compose file complete, a single command will build your API image, pull the PostgreSQL and Redis images, and start all three containers in the correct order.
- Build and start all services by running this command in your terminal (from the market-data-api project folder):
docker compose up --build
What does this command do?
docker compose up reads your compose.yaml and starts all defined services. The --build flag forces Docker to rebuild the api image from your Dockerfile before launching.
You will see logs from all three services interleaved in your terminal. Watch for the db and cache services reporting ready before the api service starts.
- Wait for the logs to settle. You should see a line indicating uvicorn is running on port 8000.
- Open your browser and navigate to http://localhost:8000/health.
You should see this JSON response confirming your API is running and connected:
{"status": "healthy", "service": "market-data-api"}
- Open a second terminal window and check the status of all containers by running:
docker compose ps
You should see all three services listed with a status of Up (healthy).
API container keeps restarting?
If the api service exits immediately, check the logs with docker compose logs api. Common causes:
- A typo in compose.yaml environment variables (e.g., DATABASE_HOST must be db, matching the service name).
- The .env file is missing or has a typo in POSTGRES_USER or POSTGRES_PASSWORD.
- Port 8000 is already in use by another process on your machine.
help me debug why my api container keeps restarting in docker compose
Your entire data platform is now running from a single command. Next up, you will ingest real stock market data and see the full pipeline in action.
Ingest and Query Financial Data
Your three-container platform is running and healthy. Now it is time to put it to work with real financial data.
In this step, you will ingest stock market data from Alpha Vantage into PostgreSQL, query it through the API, and observe Redis caching in action. You will also prove that your named volume keeps data safe across container restarts.
In this step, get ready to:
- Ingest IBM stock data via the API and store it in PostgreSQL.
- Query stock data and observe Redis caching behaviour.
- Verify data persistence across container restarts using the named volume.
Ingest stock data from Alpha Vantage
The /ingest/{symbol} endpoint triggers a POST request that fetches daily stock data from the Alpha Vantage API and stores it in your PostgreSQL database.
- Ingest IBM stock data by running this command in your terminal:
curl -X POST http://localhost:8000/ingest/IBM
What does this command do?
`curl -X POST` sends a POST request to your containerized FastAPI service. The /ingest/IBM path tells the API to fetch IBM's daily stock prices from Alpha Vantage and insert them into the stock_prices table in PostgreSQL.
The endpoint uses an ON CONFLICT clause, so running the command again updates existing records instead of creating duplicates.
- You should see a JSON response confirming the ingestion was successful.
The response includes a records_inserted count showing how many days of stock data were stored.
Seeing a 502 error?
A 502 response means the API could not reach Alpha Vantage. Check that your .env file contains a valid API key (not the placeholder your-api-key-here). If you updated the key after starting containers, run docker compose down and docker compose up --build to reload the environment.
Also note that the free tier allows 25 requests per day. If you have exceeded this limit, wait until the next day.
help me debug a 502 error from my /ingest endpoint
Query data and observe Redis caching
Now that data is stored in PostgreSQL, the /stocks/{symbol} endpoint can serve it. On the first request, it reads from the database. On subsequent requests, it serves from Redis cache for faster response times.
- Query IBM stock data by running this command:
curl http://localhost:8000/stocks/IBM
- Look at the response JSON. You should see "source": "database" in the output, confirming the data came directly from PostgreSQL.
- Run the same command again:
curl http://localhost:8000/stocks/IBM
- This time the response shows "source": "cache" instead of "database". Redis served the cached copy, which is faster than querying PostgreSQL directly.
Why does caching matter?
In production, database queries are expensive. Your set_cached_data function stores query results in Redis with a 300-second expiry (ex=300). Any request within that window skips PostgreSQL entirely.
This pattern is common in data APIs where the underlying data changes infrequently but is queried often.
- Verify that Redis is receiving data by checking the cache service logs:
docker compose logs cache
- You should see Redis startup messages and connection activity confirming the cache service is handling requests from your API.
Not seeing cache as the source?
If both requests return "source": "database", the Redis connection might be failing silently. Run docker compose ps and confirm the cache service shows a healthy status.
The get_cached_data function catches redis.ConnectionError and falls back to the database, so a misconfigured Redis host name would not produce an error. It just silently skips the cache.
help me figure out why my Redis cache is not being used
Confirm data persists across restarts
A named volume in Docker Compose keeps data alive even when containers are destroyed and recreated. This is critical for databases. Without it, every restart would wipe your data.
- Stop all services by running:
docker compose down
- This removes the containers but preserves the pgdata named volume where PostgreSQL stores its data.
- Restart the services in detached mode by running:
docker compose up -d
What does the -d flag do?
The -d flag starts containers in the background (detached mode). Instead of streaming logs to your terminal, Compose exits immediately and the containers keep running.
Use docker compose logs to view output from detached containers at any time.
- Wait a few seconds for the health checks to pass, then query the data again:
curl http://localhost:8000/stocks/IBM
- You should see the same IBM stock data returned with "source": "database". The data survived a full container restart because the named volume pgdata kept PostgreSQL's files intact.
Why does the source show database again?
Redis stores data in memory. When you ran docker compose down, the cache container was destroyed along with its in-memory data. The cache is now empty, so the first request after restart hits the database.
PostgreSQL persists data to disk via the pgdata volume, which is why the stock records survived.
- Finally, clean up everything including volumes by running:
docker compose down -v
What does the -v flag add?
The -v flag removes named volumes along with the containers. This destroys the pgdata volume and all stored stock data permanently.
Use this when you want a completely fresh start or when cleaning up a project you no longer need.
Getting a connection refused error after restart?
If curl returns a connection error right after docker compose up -d, the containers have not finished their health checks yet. Wait 10-15 seconds and try again.
Run docker compose ps to confirm all three services show a healthy status before querying the API.
help me troubleshoot connection refused after docker compose up
Your data platform is fully operational. You have ingested real market data, proven that caching works, and confirmed that named volumes keep your database safe across restarts.
Secret mission
Add a Background Data Worker Service
In production, API containers don't fetch their own data. A separate worker container handles ingestion on a schedule. Add a 4th service to your compose.yaml that periodically pulls stock data for a watchlist of symbols, turning your platform into a true multi-container data pipeline.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker Desktop, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker Desktop installed locally (free for personal use).
- Docker images built for the market-api project.
- PostgreSQL data stored in the pgdata named volume.
- Project files in the market-data folder on your Desktop.
Keep everything running
No action needed. Choose this if you plan to keep building on the project or want to reference the code later.
- Docker Desktop stays installed for future projects.
- Your built images persist locally until you manually remove them.
- The pgdata volume retains all ingested stock data.
- If containers are still running, they will continue using system resources. Stop them anytime with docker compose down.
Pause - I'll come back to this later
Shut down running containers to free up memory and CPU. Your data persists in the named volume, so you can restart anytime.
- Stop all containers by running this command in the project folder:
docker compose down
- This stops and removes all four containers (api, worker, db, cache) but keeps your images and the pgdata volume intact.
- When you want to pick back up, restart everything by running:
docker compose up -d
- Your stock data will still be there. The -d flag runs containers in detached mode so your terminal stays free.
Delete - I don't want to use this again
Remove all project containers, volumes, images, and local files. This is a full cleanup.
- Remove all containers, volumes, and images for this project by running:
docker compose down -v --rmi all
- This removes the running containers, the pgdata volume (all ingested stock data), and the Docker images built for this project.
- Delete the project folder from your Desktop. On macOS or Linux, run:
cd ~/Desktop
rm -rf market-data
- On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force market-data
- If you no longer need Docker Desktop, uninstall it.
macOS
- Open Docker Desktop.
- Click the Troubleshoot icon in the top-right corner.
- Select Uninstall.
- Confirm by selecting Uninstall again.
- Move the Docker app to the Trash.
Windows
- Open Settings from the Start menu.
- Select Apps, then Apps & features.
- Find Docker Desktop in the list and select Uninstall.
- Confirm by selecting Uninstall.
Nice Work!
Nice Work!
Nice work! You just built a production-grade multi-container financial data platform using Docker, FastAPI, PostgreSQL, and Redis.
You've learned how to:
- Built a multi-container data platform that ingests real stock market data from Alpha Vantage and serves it through a cached API layer.
- Wrote a multi-stage Dockerfile that separates build tools from runtime to produce an optimized production image.
- Orchestrated 3 services with Docker Compose including health checks, dependency ordering, custom networks, and named volumes for data persistence.
- Secret Mission: Added a background worker service for automated data ingestion, creating a 4-container architecture.
Ready to quiz yourself?