Deploying Applications
Writing an app is only half the job; someone has to be able to reach it. In this lab we build a small Task Manager API with FastAPI and SQLite, package it with Docker, run it behind Nginx with Docker Compose, and finish by looking at how a domain name gets pointed at the server.
Learning Objectives
By the end of this notebook you will be able to:
- Explain how HTTP and REST APIs work and how clients use them
- Build a REST API with FastAPI, backed by a SQLite database
- Package an application into a Docker image
- Run several services together with Docker Compose
- Put Nginx in front of a FastAPI app as a reverse proxy
- Explain how a DNS A record points a domain name at a server
Prerequisites
- Python 3.10 or newer
- Docker Desktop installed and running (check with
docker --version) - Basic terminal use
- Comfortable with Python functions and classes
Install the Python packages before you start Section 3:
pip install fastapi 'uvicorn[standard]' sqlalchemy
How to Use This Notebook
- Sections 1 and 2 are background. From Section 3 on it's a step-by-step lab: work through the numbered steps in order, because each one builds on the last.
- Cells that start with
%%writefilesave a file for you when you run them (into ataskapi/folder next to this notebook). For the other files (Dockerfile,nginx.conf, ...) create the file in your editor and paste in the contents shown. - Commands in
bashblocks are for your terminal, run from inside thetaskapi/folder unless the step says otherwise. - After the important steps there's a Checkpoint telling you what you should see. If you don't see it, fix that step before moving on.
- Try the practice tasks and the challenge yourself before opening Show solution. Each section ends with a Quick recap for revision.
The Big Picture: What We're Building
By the end of the lab, every request from a browser or curl travels through this stack:
Browser / curl
|
| HTTP, port 80
v
[ Nginx ] <-- reverse proxy, accepts incoming connections
|
| HTTP, port 8000 (internal Docker network)
v
[ FastAPI / uvicorn ] <-- application server, runs your Python code
|
| SQL
v
[ SQLite ] <-- file-based database, kept in a Docker volume
What Each Layer Does
| Layer | Tool | Job |
|---|---|---|
| Reverse proxy | Nginx | Accepts public traffic and forwards it to the app server |
| Application server | FastAPI + uvicorn | Runs the business logic, validates input, queries the database |
| Database | SQLite | Stores the data so it survives restarts |
| Container runtime | Docker | Packages each service so it runs the same everywhere |
| Orchestration | Docker Compose | Starts all the containers, wired together, with one command |
The Running Example: a Task Manager API
We build one REST API for managing tasks. Each section adds one layer to the same app until the full stack is running.
| Method | Path | Action |
|---|---|---|
GET |
/tasks |
List all tasks |
POST |
/tasks |
Create a new task |
PATCH |
/tasks/{id}/complete |
Mark a task as completed |
1. REST APIs and HTTP
What Is an API?
An API (Application Programming Interface) is an agreed way for one program to talk to another. A web API uses HTTP, the same protocol your browser uses to load web pages.
When you open https://weather.com, your browser sends an HTTP request and gets back an HTML page. When your Python code calls a weather API, it sends the same kind of request and gets back JSON. Same protocol, different response format.
What Is REST?
REST (Representational State Transfer) is a set of conventions for designing web APIs:
- Each resource has a URL (
/tasks,/tasks/42). - The HTTP method says what you want to do with it.
- The response's status code says how it went.
- Data goes back and forth as JSON.
An API that follows these conventions is called RESTful.
HTTP Methods
| Method | Meaning | Typical use |
|---|---|---|
GET |
Read a resource | List tasks, get one task |
POST |
Create a new resource | Add a task |
PUT |
Replace a resource completely | Replace all of a task's fields |
PATCH |
Update part of a resource | Mark a task complete |
DELETE |
Remove a resource | Delete a task |
HTTP Status Codes
| Code | Meaning | When it happens |
|---|---|---|
200 OK |
Success | A GET returned data |
201 Created |
Resource created | A POST succeeded |
204 No Content |
Success, no response body | A DELETE succeeded |
400 Bad Request |
The server won't accept the request | e.g. a business rule fails, like a duplicate username |
404 Not Found |
The resource doesn't exist | Task ID not in the database |
422 Unprocessable Entity |
Validation failed | Missing field or wrong type in the body (FastAPI's default for bad input) |
500 Internal Server Error |
The server crashed | A bug in your code |
Rule of thumb: 2xx means success, 4xx means the client got something wrong, 5xx means the server got something wrong.
What a Request and Response Look Like
POST /tasks HTTP/1.1
Host: localhost:8000
Content-Type: application/json
{"title": "Buy groceries"}
HTTP/1.1 201 Created
Content-Type: application/json
{"id": 1, "title": "Buy groceries", "completed": false, "created_at": "2026-04-16T10:00:00"}
Every HTTP exchange has this shape. Going out: method + URL + headers + optional body. Coming back: status code + headers + optional body.
Quick recap
- A web API lets programs talk over HTTP, usually exchanging JSON.
- REST: URLs name resources, methods (GET/POST/PATCH/DELETE) say what to do.
- Status codes: 2xx success, 4xx client error, 5xx server error.
2. Clients: Who Calls Your API?
A client is anything that sends HTTP requests to your API. The API doesn't know or care what kind of client it is; it only sees the request.
| Client | Typical use |
|---|---|
| Web browser (JavaScript) | A web page calls the API with fetch() |
curl |
Testing APIs by hand in the terminal |
| Postman | GUI tool for building and testing requests |
Python requests / httpx |
Calling APIs from scripts and other services |
| Mobile app | iOS/Android apps talking to their backend |
| Another server | Microservices calling each other |
How a Browser Uses an API
When a web page needs data, its JavaScript calls fetch:
// JavaScript running in the browser
const response = await fetch('http://localhost:8000/tasks');
const tasks = await response.json();
console.log(tasks);
// [{"id": 1, "title": "Buy groceries", "completed": false, ...}]
The browser sends a GET request to your FastAPI server and gets JSON back. Your Python code can't tell whether the caller was a browser, a mobile app or curl.
One catch: if the web page comes from a different address than the API (say localhost:3000 calling localhost:8000), the browser blocks the response unless the API allows it with CORS headers. FastAPI has a CORSMiddleware for this. We won't need it here, but it's the first thing to check when fetch fails in the browser and curl works fine.
Testing with curl
curl is the quickest way to try an API from the terminal. Here's a preview of the commands we'll use once the server is running:
# List all tasks
curl http://localhost:8000/tasks
# Create a new task
curl -X POST http://localhost:8000/tasks \
-H 'Content-Type: application/json' \
-d '{"title": "Buy groceries"}'
# Mark task 1 as completed
curl -X PATCH http://localhost:8000/tasks/1/complete
Windows users: the single quotes and \ line breaks above work in Git Bash and WSL, but not in Command Prompt or PowerShell. Use Git Bash, or use the Swagger UI described next.
FastAPI's Built-in Client: Swagger UI
FastAPI automatically builds interactive documentation at /docs. You can send real requests straight from the browser, with no curl or Postman needed. Once the server is running (Section 3), open:
http://localhost:8000/docs
Quick recap
- Any program that sends HTTP requests is a client: browsers,
curl, Postman, Python scripts, apps. - Browsers call APIs with
fetch(); a different origin needs CORS on the API side. curland Swagger UI (/docs) are the two quickest ways to test your API.
3. FastAPI: Your First Endpoints
What Is FastAPI?
FastAPI is a modern Python framework for building APIs. What makes it nice to work with:
- Automatic validation. It uses your type hints (through Pydantic) to check incoming data, and replies with a
422and a clear message when the data is wrong. - Free documentation. Swagger UI at
/docs, with no extra code. - Dependency injection. A clean way to share things like database sessions across routes.
- Speed. It's one of the fastest Python frameworks, built on Starlette and uvicorn.
Step 1: Create the Project Folder
We'll end up with this structure (more files get added in later sections):
taskapi/
└── app/
└── main.py
Run the cell below to create the folders, or create them yourself in your editor.
from pathlib import Path
Path("taskapi/app").mkdir(parents=True, exist_ok=True)
print("Created taskapi/app/")
Created taskapi/app/
Step 2: Write main.py (Version 1, In-Memory)
This first version keeps tasks in a plain Python list. There's no database yet, so restarting the server wipes everything. Section 4 fixes that.
Run the cell to save it as taskapi/app/main.py.
%%writefile taskapi/app/main.py
# taskapi/app/main.py (version 1: in-memory storage)
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from datetime import datetime, timezone
app = FastAPI(title='Task Manager API')
# In-memory storage. Replaced with SQLite in Section 4.
tasks = []
_next_id = 1
class TaskCreate(BaseModel):
title: str
class TaskResponse(BaseModel):
id: int
title: str
completed: bool
created_at: str
@app.get('/tasks', response_model=list[TaskResponse])
def list_tasks():
return tasks
@app.post('/tasks', response_model=TaskResponse, status_code=201)
def create_task(task: TaskCreate):
global _next_id
new_task = {
'id': _next_id,
'title': task.title,
'completed': False,
'created_at': datetime.now(timezone.utc).isoformat(),
}
tasks.append(new_task)
_next_id += 1
return new_task
@app.patch('/tasks/{task_id}/complete', response_model=TaskResponse)
def complete_task(task_id: int):
for task in tasks:
if task['id'] == task_id:
task['completed'] = True
return task
raise HTTPException(status_code=404, detail='Task not found')
Overwriting taskapi/app/main.py
What just happened? We saved the file; nothing is running yet. A few things to notice in the code:
TaskCreatedescribes what a client must send (just atitle).TaskResponsedescribes what we send back.@app.post('/tasks', ..., status_code=201)connects a URL and method to a function and sets the success code.task_id: intin the PATCH route is read from the URL, and FastAPI converts it to anintfor us.raise HTTPException(status_code=404, ...)is how you return an error.
Step 3: Start the Server
In a terminal, go into the taskapi/ folder and run:
uvicorn app.main:app --reload
app.main:app means "in the module app/main.py, use the object called app". The --reload flag restarts the server whenever you save a file.
Checkpoint: the terminal shows Uvicorn running on http://127.0.0.1:8000. Opening http://localhost:8000/tasks in a browser shows [] (an empty list), and http://localhost:8000/docs shows the Swagger UI.
Leave the server running and open a second terminal for the next step.
Step 4: Try It with curl
# Create two tasks
curl -X POST http://localhost:8000/tasks \
-H 'Content-Type: application/json' \
-d '{"title": "Buy groceries"}'
curl -X POST http://localhost:8000/tasks \
-H 'Content-Type: application/json' \
-d '{"title": "Write tests"}'
# List all tasks
curl http://localhost:8000/tasks
# Complete task 1
curl -X PATCH http://localhost:8000/tasks/1/complete
Checkpoint: the list shows two tasks with ids 1 and 2, and after the PATCH, task 1 comes back with "completed": true. Try curl -X PATCH http://localhost:8000/tasks/99/complete too: you should get {"detail":"Task not found"}.
Step 5: See Validation in Action
Send a bad request that leaves out the required title:
curl -X POST http://localhost:8000/tasks \
-H 'Content-Type: application/json' \
-d '{}'
FastAPI replies with 422 Unprocessable Entity and explains exactly what's wrong. We didn't write any validation code for this:
{
"detail": [
{"type": "missing", "loc": ["body", "title"], "msg": "Field required", "input": {}}
]
}
loc tells you where the problem is (the title field in the request body).
Now stop the server (Ctrl+C) and start it again. Run curl http://localhost:8000/tasks: the list is empty again, because the tasks only lived in memory.
Quick recap
- A FastAPI route is a function with a decorator like
@app.get('/tasks'). - Pydantic models (
TaskCreate,TaskResponse) define what comes in and goes out; bad input gets a422. uvicorn app.main:app --reloadruns the app;/docsgives you a free test UI.- In-memory data disappears on every restart, so we need a database.
4. Adding a SQLite Database
What Is SQLite?
SQLite is a SQL database that lives in a single file. There's no separate database server to install or configure.
| Property | SQLite |
|---|---|
| Storage | One file (tasks.db) |
| Setup | None, it works out of the box (it's built into Python) |
| Good for | Development, small-to-medium apps, embedded systems |
| Not good for | Lots of users writing at the same time |
It's a good fit for this course project and for many apps that never need more than one server.
SQLAlchemy
SQLAlchemy is the most widely used ORM (Object-Relational Mapper) for Python. You describe tables as Python classes and write queries in Python instead of raw SQL:
Python class ----> database table
class Task maps to CREATE TABLE tasks (...)
Python object ----> database row
Task(...) maps to INSERT INTO tasks ...
We'll add one new file, database.py, next to main.py:
taskapi/
└── app/
├── main.py <-- updated (uses the database)
└── database.py <-- new (engine, session, ORM model)
Step 6: Write database.py
%%writefile taskapi/app/database.py
# taskapi/app/database.py
import os
from datetime import datetime, timezone
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import declarative_base, sessionmaker
DATABASE_URL = os.getenv('DATABASE_URL', 'sqlite:///./tasks.db')
engine = create_engine(DATABASE_URL, connect_args={'check_same_thread': False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def utc_now():
return datetime.now(timezone.utc)
class Task(Base):
__tablename__ = 'tasks'
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
completed = Column(Boolean, default=False)
created_at = Column(DateTime, default=utc_now)
def get_db():
"""Yield a database session; close it when the request is done."""
db = SessionLocal()
try:
yield db
finally:
db.close()
def create_tables():
"""Create all tables if they do not already exist."""
Base.metadata.create_all(bind=engine)
Overwriting taskapi/app/database.py
What just happened? This file sets up three things:
engineis the connection to the database. The URL comes from theDATABASE_URLenvironment variable, or falls back tosqlite:///./tasks.db(a file calledtasks.dbin the current folder). We'll use that environment variable later with Docker.SessionLocalcreates sessions, which are like a conversation with the database for one request.Taskis the table: each class attribute becomes a column.
check_same_thread=False is a SQLite-only setting. FastAPI may handle one request across different threads, and SQLite blocks that by default.
Step 7: Update main.py (Version 2, SQLite)
Replace main.py with the version below. The three routes do exactly the same job as before; only the storage changes from a Python list to SQLAlchemy queries.
%%writefile taskapi/app/main.py
# taskapi/app/main.py (version 2: SQLite backend)
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import Task, get_db, create_tables
app = FastAPI(title='Task Manager API')
create_tables() # creates tasks.db and the tasks table on first run
class TaskCreate(BaseModel):
title: str
class TaskResponse(BaseModel):
id: int
title: str
completed: bool
created_at: str
def _task_to_dict(task: Task) -> dict:
return {
'id': task.id,
'title': task.title,
'completed': task.completed,
'created_at': task.created_at.isoformat(),
}
@app.get('/tasks', response_model=list[TaskResponse])
def list_tasks(db: Session = Depends(get_db)):
return [_task_to_dict(t) for t in db.query(Task).all()]
@app.post('/tasks', response_model=TaskResponse, status_code=201)
def create_task(task: TaskCreate, db: Session = Depends(get_db)):
db_task = Task(title=task.title)
db.add(db_task)
db.commit()
db.refresh(db_task)
return _task_to_dict(db_task)
@app.patch('/tasks/{task_id}/complete', response_model=TaskResponse)
def complete_task(task_id: int, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail='Task not found')
task.completed = True
db.commit()
db.refresh(task)
return _task_to_dict(task)
Overwriting taskapi/app/main.py
Step 8: Run It and Check the Data Survives
cd taskapi/
uvicorn app.main:app --reload
The first time the app starts, it creates tasks.db in the taskapi/ folder. Create a couple of tasks with the curl commands from Step 4, then stop and restart the server.
Checkpoint: after the restart, curl http://localhost:8000/tasks still shows your tasks.
Looking Inside the Database
If you have the sqlite3 command-line tool installed, you can look at the file directly:
sqlite3 tasks.db
sqlite> .tables
tasks
sqlite> SELECT * FROM tasks;
1|Buy groceries|0|2026-04-16 10:00:00.000000
2|Write tests|0|2026-04-16 10:01:00.000000
sqlite> .quit
SQLite stores True/False as 1/0.
How Depends(get_db) Works
Depends(get_db) is FastAPI's dependency injection. For every request:
- FastAPI calls
get_db(), which opens a database session. - It passes that session into your route function as
db. - After the response is sent, it runs the
finallyblock inget_db(), which closes the session.
Your route functions never have to open or close sessions themselves.
Quick recap
- SQLite is a whole database in one file; SQLAlchemy lets you use it through Python classes.
DATABASE_URLcomes from an environment variable, with a localtasks.dbas the default.Depends(get_db)gives each request its own session and closes it afterwards.- Data now survives a server restart.
5. Docker
Containers vs Virtual Machines
The classic deployment problem: "It works on my machine."
A virtual machine solves this by shipping a whole operating system. A container is lighter: it shares the host's OS kernel but packages your app and everything it needs into an isolated unit that runs the same everywhere.
| Virtual Machine | Container | |
|---|---|---|
| Includes | Full OS + app | App + dependencies only |
| Starts in | Minutes | Seconds |
| Size | Gigabytes | Megabytes |
| Isolation | Separate kernel | Separate process (shared kernel) |
| Use case | Running a different operating system | Packaging and deploying apps |
How Docker Works
Dockerfile docker build docker run
(recipe) ---------------> Image ---------------> Container
(snapshot) (running instance)
- Dockerfile: the recipe for building an image.
- Image: a read-only snapshot of your app and its environment. Think of it as a ZIP file you can run.
- Container: a running copy of an image. You can start many containers from one image.
We'll add two files to the project root:
taskapi/
├── app/
│ ├── main.py
│ └── database.py
├── Dockerfile <-- new
└── requirements.txt <-- new
Step 9: Create requirements.txt
This lists the exact package versions the container will install. Create taskapi/requirements.txt:
fastapi==0.111.0
uvicorn[standard]==0.29.0
sqlalchemy==2.0.30
Pinning exact versions (==) means the image you build next month gets the same packages as the one you build today.
Step 10: Create the Dockerfile
This Dockerfile uses a multi-stage build. The first stage (builder) installs the dependencies into a virtual environment. The second stage (runtime) copies only that environment and your code, so the final image doesn't carry any build leftovers.
Create taskapi/Dockerfile (no file extension):
# Stage 1: builder - install dependencies into a virtual environment
FROM python:3.12-slim AS builder
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Stage 2: runtime - lean final image, no build tools
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY app/ ./app/
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# Run as a non-root user (security best practice).
# The user needs a folder it can write the database into.
RUN adduser --disabled-password --gecos "" appuser \
&& mkdir -p /app/data \
&& chown appuser /app/data
ENV DATABASE_URL=sqlite:////app/data/tasks.db
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
What's going on in the last few lines? The app runs as an ordinary user called appuser, not as root, so a bug in the app can't take over the whole container. But an ordinary user can't write into /app, which belongs to root. So we create /app/data, hand it to appuser, and point DATABASE_URL at it. Without this, the app would crash on start-up with unable to open database file.
Step 11: Build the Image and Run a Container
Run these from the taskapi/ folder. Stop the uvicorn server from Step 8 first, because both use port 8000.
Build the image (the . means "use the Dockerfile in this folder"):
docker build -t taskapi .
Run a container from it:
docker run -p 8000:8000 taskapi
-p 8000:8000 connects port 8000 on your machine to port 8000 inside the container.
Checkpoint: the build finishes without errors, the container prints Uvicorn running on http://0.0.0.0:8000, and curl http://localhost:8000/tasks returns []. The app is now running inside a container. (It's an empty list because the container has its own fresh database.)
Other Useful Docker Commands
docker ps # list running containers
docker logs <container-id> # show a container's output
docker exec -it <container-id> sh # open a shell inside a running container
docker stop <container-id> # stop a container
docker images # list images
docker rmi taskapi # remove the image
Note that the database file lives inside the container's own filesystem. If you remove the container, the data goes with it. Section 6 fixes this with a Docker volume.
Quick recap
- An image is a snapshot built from a Dockerfile; a container is a running copy of an image.
- A multi-stage build keeps the final image small.
- Run as a non-root user, and give that user a folder it can write to.
docker build -t name .builds,docker run -p host:container nameruns.
6. Docker Compose
What Is Docker Compose?
Docker Compose lets you describe one or more containers in a YAML file and start them all with one command. Instead of remembering a long docker run command full of flags, you write the settings down once in docker-compose.yml.
docker-compose.yml
|
v
docker compose up --build
|
├── starts container: app (your FastAPI service)
└── (an nginx container joins it in Section 7)
Step 12: Create docker-compose.yml (Version 1)
Create taskapi/docker-compose.yml:
services:
app:
build: .
ports:
- "8000:8000"
volumes:
- taskdata:/app/data
environment:
- DATABASE_URL=sqlite:////app/data/tasks.db
volumes:
taskdata:
What each part does:
| Field | Purpose |
|---|---|
build: . |
Build the image from the Dockerfile in this folder |
ports |
Connect port 8000 on your machine to port 8000 in the container |
volumes |
Store /app/data in a Docker volume called taskdata, so tasks.db survives when the container is removed |
environment |
Set DATABASE_URL so the app writes tasks.db into that volume |
top-level volumes: |
Declares the taskdata volume |
The four slashes in sqlite:////app/data/tasks.db are correct: sqlite:/// is the start of the URL, and /app/data/tasks.db is an absolute path.
Why a named volume and not a folder on your machine? You'll often see ./data:/app/data instead, which uses a folder on your computer. That works on Docker Desktop, but on a Linux server the folder is created as root and our non-root appuser can't write to it. A named volume avoids that problem everywhere.
Step 13: Start the Stack
docker compose up --build
Checkpoint: create a task with curl (Step 4 commands), then stop everything with Ctrl+C followed by docker compose down. Run docker compose up again and list the tasks: your task is still there, because it lives in the taskdata volume.
To peek at the database inside the running container (the slim image has no sqlite3 tool, but it has Python):
docker compose exec app python -c "import sqlite3; print(sqlite3.connect('/app/data/tasks.db').execute('SELECT * FROM tasks').fetchall())"
Docker Compose Commands
docker compose up --build # build images and start everything (in the foreground)
docker compose up --build -d # same, but in the background
docker compose logs -f # follow the logs
docker compose down # stop and remove the containers (volumes are kept)
docker compose down -v # ...and delete the volumes too (this deletes your data!)
Quick recap
docker-compose.ymldescribes your containers;docker compose up --buildstarts them.- A named volume keeps
tasks.dbsafe when containers are removed. docker compose downkeeps your data;docker compose down -vdeletes it.
7. Nginx as a Reverse Proxy
What Is a Reverse Proxy?
A reverse proxy is a server that sits in front of your app and passes incoming requests on to it.
Without Nginx:
Browser --> FastAPI :8000
With Nginx:
Browser --> Nginx :80 --> FastAPI :8000 (internal only)
Why add another layer?
| Benefit | Explanation |
|---|---|
| Standard ports | Users connect on port 80 (HTTP) or 443 (HTTPS) instead of :8000 |
| HTTPS | Nginx handles the encryption; your app only ever sees plain HTTP inside |
| Static files | Nginx serves images and CSS directly, without bothering Python |
| Load balancing | Nginx can share traffic between several copies of your app |
In this course we only use the first one: Nginx as a reverse proxy on port 80.
The project gets a new folder:
taskapi/
├── app/
│ ├── main.py
│ └── database.py
├── nginx/
│ └── nginx.conf <-- new
├── Dockerfile
├── docker-compose.yml <-- updated
└── requirements.txt
Step 14: Create nginx/nginx.conf
Create the folder taskapi/nginx/ and the file taskapi/nginx/nginx.conf:
upstream taskapi {
server app:8000;
}
server {
listen 80;
location / {
proxy_pass http://taskapi;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
| Directive | Meaning |
|---|---|
upstream taskapi |
Names the group of app servers to forward to. app is the service name from docker-compose.yml; Docker's built-in DNS turns it into the container's IP address. |
listen 80 |
Accept connections on port 80 |
proxy_pass http://taskapi |
Forward every request to the taskapi upstream |
proxy_set_header |
Pass along the original host and client IP so the app can log them |
Step 15: Update docker-compose.yml (Version 2)
Replace taskapi/docker-compose.yml with this version, which adds the nginx service:
services:
app:
build: .
expose:
- "8000"
volumes:
- taskdata:/app/data
environment:
- DATABASE_URL=sqlite:////app/data/tasks.db
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- app
volumes:
taskdata:
What changed from version 1:
| Change | Reason |
|---|---|
ports became expose on app |
The app is no longer reachable from outside; only Nginx can talk to it |
New nginx service |
Runs the official nginx:alpine image |
ports: "80:80" on nginx |
Nginx is now the only way in |
| Our config mounted into Nginx | Replaces Nginx's default site with our nginx.conf |
depends_on: app |
Compose starts app before nginx |
Step 16: Test Through Nginx
docker compose up --build
In a second terminal (note: no port number, because port 80 is the default for HTTP):
curl http://localhost/tasks
curl -X POST http://localhost/tasks \
-H 'Content-Type: application/json' \
-d '{"title": "Test through Nginx"}'
Checkpoint: both commands work, and the task you created in Step 13 is still in the list (same volume). Run curl -i http://localhost/tasks to see the response headers: there's a Server: nginx/... line, which proves the request went through Nginx. And curl http://localhost:8000/tasks should now fail, because the app is no longer exposed directly.
If port 80 is already in use on your machine, change "80:80" to "8080:80" and use http://localhost:8080 instead.
Quick recap
- A reverse proxy accepts public traffic and forwards it to your app.
- In Compose, services find each other by name (
server app:8000). exposekeeps the app internal; only Nginx publishes a port (80:80).
8. Putting It All Together
Final Project Structure
taskapi/
├── app/
│ ├── main.py # FastAPI routes, Pydantic models
│ └── database.py # SQLAlchemy engine, session, ORM model
├── nginx/
│ └── nginx.conf # reverse proxy config
├── Dockerfile # multi-stage build for the FastAPI app
├── docker-compose.yml # runs app + nginx, declares the taskdata volume
└── requirements.txt # pinned Python dependencies
(The tasks.db file from Step 8 may also be sitting in taskapi/. It's only used when you run uvicorn directly; the containers use the taskdata volume.)
Starting the Full Stack From Scratch
cd taskapi/
docker compose up --build
That one command builds the FastAPI image, pulls the Nginx image, creates a private network and the volume, and starts both containers.
Following One Request: curl http://localhost/tasks
1. curl sends GET /tasks HTTP/1.1 to 127.0.0.1:80
2. Nginx receives the request on port 80.
nginx.conf says: location / -> proxy_pass http://taskapi
Nginx forwards GET /tasks to app:8000 (found with Docker's internal DNS)
3. uvicorn receives the request on port 8000.
FastAPI matches the route @app.get('/tasks').
Depends(get_db) opens a SQLAlchemy session.
SQLAlchemy runs SELECT ... FROM tasks on /app/data/tasks.db.
FastAPI returns JSON with status 200.
4. Nginx passes the JSON response back to curl unchanged.
5. curl prints the JSON.
Who Does What?
| Question | Answer |
|---|---|
| Who accepts the public connection? | Nginx |
| Who runs the Python code? | uvicorn, inside the app container |
| Who validates the request body? | FastAPI + Pydantic |
| Who talks to the database? | SQLAlchemy, inside the app container |
| Who keeps the data across restarts? | The taskdata Docker volume |
| Who wires everything together? | Docker Compose |
Quick recap
- One
docker compose up --buildstarts the whole stack. - A request goes curl -> Nginx (:80) -> uvicorn/FastAPI (:8000) -> SQLite, and back.
- Each layer has one job, so you can change one without touching the others.
9. DNS and Domain Names
So far everything ran on localhost. To put the app on the internet, you'd run the same docker compose up on a server, then point a domain name at that server. This section explains that last part. You don't need a server or a domain to follow along.
What Is DNS?
Every server on the internet has an IP address, a number like 203.0.113.42. DNS (Domain Name System) is the internet's phone book: it turns names like tasks.example.com into IP addresses that computers can connect to.
Without DNS, you'd type http://203.0.113.42/tasks instead of http://tasks.example.com/tasks.
How a Name Gets Looked Up
When you type tasks.example.com into a browser:
Browser
|
| 1. Check the browser's cache (visited recently?)
v
Operating system
|
| 2. Check the OS cache and the hosts file (/etc/hosts)
v
Recursive resolver (from your ISP, or a public one like 8.8.8.8)
|
| 3. Ask: who handles .com?
v
Root name server
|
| 4. Answers: ask the .com TLD server
v
TLD name server (.com)
|
| 5. Answers: ask example.com's authoritative name server
v
Authoritative name server (your DNS provider)
|
| 6. Answers: tasks.example.com -> 203.0.113.42
v
Browser connects to 203.0.113.42 on port 80
The answer is cached along the way, so the full lookup only happens again once the cached copy expires (its TTL runs out).
A Records
An A record (Address record) maps a hostname to an IPv4 address. It's the most basic DNS record.
| Type | Name | Value | TTL |
|---|---|---|---|
| A | tasks.example.com |
203.0.113.42 |
3600 |
| A | www.example.com |
203.0.113.42 |
3600 |
| A | example.com |
203.0.113.42 |
3600 |
TTL (Time To Live) is how many seconds resolvers may cache the record. 3600 is one hour. A low TTL means changes spread faster; a high TTL means fewer lookups.
Other Common Record Types
| Type | Purpose | Example |
|---|---|---|
A |
Hostname -> IPv4 address | tasks.example.com -> 203.0.113.42 |
AAAA |
Hostname -> IPv6 address | tasks.example.com -> 2001:db8::1 |
CNAME |
Alias: hostname -> another hostname | www.example.com -> example.com |
MX |
Mail server for the domain | example.com -> mail.example.com |
TXT |
Free text, used for domain verification, SPF, etc. | "v=spf1 include:..." |
What a Zone File Looks Like
A zone file holds all the records for a domain. Most DNS providers hide it behind a web form, but underneath it looks like this:
; Zone file for example.com
$TTL 3600
@ IN A 203.0.113.42 ; example.com -> server IP
www IN A 203.0.113.42 ; www.example.com -> server IP
tasks IN A 203.0.113.42 ; tasks.example.com -> server IP
@ means the domain itself (example.com).
Pointing a Domain at Your Server, Step by Step
- Buy a domain from a registrar such as Namecheap, Porkbun or Cloudflare.
- Find your server's public IP address: the IP of the machine running
docker compose up. - Log in to your DNS provider (often the same company as the registrar, or Cloudflare).
- Add an A record:
- Name:
tasks(or@for the bare domain) - Type:
A - Value:
203.0.113.42(your server's IP) - TTL:
3600
- Name:
- Wait for the change to spread. It usually takes 1 to 30 minutes, and up to 48 hours if old records were cached with long TTLs.
You can check the result from your terminal with nslookup tasks.example.com (or dig tasks.example.com on macOS/Linux).
How This Connects to Our Stack
Browser types: http://tasks.example.com
|
| DNS: tasks.example.com -> 203.0.113.42
v
Your server at 203.0.113.42
|
| port 80
v
[ Nginx ] <-- receives the request
|
v
[ FastAPI ] <-- handles it
|
v
[ SQLite ] <-- stores the data
Nginx is the one listening on port 80. As soon as DNS points your domain at the server, real traffic flows through exactly the stack you built in this lab.
What about HTTPS? In production you'd also have Nginx listen on port 443 with a certificate, for example a free one from Let's Encrypt using Certbot. That only changes the Nginx layer; FastAPI and SQLite stay the same.
Quick recap
- DNS turns names into IP addresses; answers are cached for the record's TTL.
- An A record maps a hostname to an IPv4 address; CNAME is an alias, MX is for mail.
- To go live: run the stack on a server, then add an A record pointing your domain at its IP.
10. Practice
Short tasks to check your understanding. Try each one before opening the solution.
Task 1: Pick the status code
Which status code should the API return in each case?
GET /tasks/7and task 7 exists.POST /taskswith the body{}. And with{"title": 123}?PATCH /tasks/999/completeand there is no task 999.DELETE /tasks/7succeeded.
Show solution
200 OK.- Both give
422 Unprocessable Entity.{}is missingtitle("Field required"), and Pydantic v2 doesn't turn the number123into a string ("Input should be a valid string"). 404 Not Found.204 No Content.
Task 2: Get one task
Add a GET /tasks/{task_id} route to the SQLite version of main.py that returns a single task, or 404 if it doesn't exist.
Show solution
@app.get('/tasks/{task_id}', response_model=TaskResponse)
def get_task(task_id: int, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail='Task not found')
return _task_to_dict(task)
Task 3: Move Nginx to port 8080
Port 80 is already taken on your laptop. What do you change so the stack is reachable at http://localhost:8080/tasks? Do you need to change nginx.conf?
Show solution
Only the host side of the port mapping in docker-compose.yml changes:
nginx:
ports:
- "8080:80"
nginx.conf stays the same: inside the container Nginx still listens on port 80. Run docker compose up -d again to apply the change.
Task 4: Add a DNS record
You own example.com, and your server's IP is 198.51.100.7. Write the record that makes api.example.com point to the server.
Show solution
| Type | Name | Value | TTL |
|---|---|---|---|
| A | api |
198.51.100.7 |
3600 |
In zone-file form: api IN A 198.51.100.7
Challenge: Add a Delete Endpoint
Extend the Task Manager API with a delete endpoint.
Requirements
- Add
DELETE /tasks/{task_id}toapp/main.py. - Return
204 No Contenton success (no response body). - Return
404 Not Foundif the task doesn't exist. - Rebuild the Docker image and bring the stack back up.
- Check it with
curl:
# Create a task and note the "id" in the response
curl -X POST http://localhost/tasks \
-H 'Content-Type: application/json' \
-d '{"title": "Delete me"}'
# Delete it (replace 1 with the id you got back)
curl -i -X DELETE http://localhost/tasks/1
# Confirm it's gone
curl http://localhost/tasks
Show solution
Add this route to app/main.py:
@app.delete('/tasks/{task_id}', status_code=204)
def delete_task(task_id: int, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail='Task not found')
db.delete(task)
db.commit()
There's no return: with status_code=204 FastAPI sends an empty body.
Rebuild and restart:
docker compose up --build
curl -i should show HTTP/1.1 204 No Content. Deleting the same id again gives 404.
Summary & Cheat Sheet
| Concept | Remember this |
|---|---|
| REST / HTTP | Methods + URLs + status codes + JSON are the language of web APIs |
| FastAPI | Type hints + Pydantic give you validation and docs for free |
| SQLAlchemy | Maps Python classes to tables; Depends(get_db) manages sessions |
| Docker | Packages your app and its environment into an image that runs anywhere |
| Docker Compose | One YAML file starts several containers, wired together, with a volume for data |
| Nginx | Sits in front of your app on port 80 (later 443 for HTTPS) |
| DNS / A records | Map a domain name to your server's IP address |
Commands You'll Use Most
uvicorn app.main:app --reload # run the app locally while developing
docker build -t taskapi . # build an image
docker run -p 8000:8000 taskapi # run a single container
docker compose up --build -d # build and start the whole stack in the background
docker compose logs -f # watch the logs
docker compose down # stop the stack (keeps the data volume)
curl -i http://localhost/tasks # test through Nginx, showing headers
Further Reading
- FastAPI documentation
- SQLAlchemy ORM tutorial
- Docker getting started guide
- Nginx beginner's guide
- The Twelve-Factor App: principles behind production-ready applications
What's Next
The next notebook, Vibe Coding, looks at building software with AI assistants. Keep this Task Manager handy: it's a good, realistic project to practise on, and you now know every layer it runs on.