This is the final article in the DevTools series. In the previous 17 articles, you learned Git, Docker, and SQL separately. Now you will use all three together to build and deploy a real project.
We will build a simple task management API with a PostgreSQL database. You will set up a Git repository with feature branches, write the API, Dockerize everything, add a CI/CD pipeline with GitHub Actions, and deploy it to a server.
By the end, you will have a complete developer workflow: branch, code, test, PR, merge, deploy.
What We Are Building
A task management API called TaskFlow. It has three endpoints:
GET /tasks— list all tasksPOST /tasks— create a new taskGET /tasks/:id— get a single task
The tech stack:
- Python + FastAPI — the API framework
- PostgreSQL — the database
- Docker + Docker Compose — containers for the app and database
- GitHub Actions — automated testing and image building
- VPS — production server
This is not a toy example. This is the same workflow used by real teams at real companies.
Step 1: Set Up the Git Repository
Start by creating a new repository. If you need a refresher, see Git Tutorial #1: Git Basics.
mkdir taskflow
cd taskflow
git init
Create a .gitignore file:
__pycache__/
*.pyc
.env
.venv/
Create a README.md:
# TaskFlow
A simple task management API built with FastAPI and PostgreSQL.
Make your first commit:
git add .gitignore README.md
git commit -m "Initial commit"
Now push it to GitHub. Create a new repository on GitHub called taskflow, then connect it:
git remote add origin git@github.com:your-username/taskflow.git
git push -u origin main
We covered this in Git Tutorial #4: GitHub Workflow. From now on, we will never commit directly to main. Every change goes through a feature branch and a pull request.
Step 2: Create a Feature Branch for the API
As we learned in Git Tutorial #2: Branching, we create a branch for each new feature:
git switch -c feature/api-setup
Now let us build the API.
Project Structure
Create these files:
taskflow/
app/
__init__.py
main.py
database.py
models.py
schemas.py
requirements.txt
Requirements
requirements.txt:
fastapi==0.115.0
uvicorn==0.30.0
sqlalchemy==2.0.35
psycopg2-binary==2.9.9
alembic==1.13.2
pytest==8.3.3
httpx==0.27.2
- FastAPI is a Python web framework. It is fast and easy to use.
- Uvicorn is the server that runs FastAPI.
- SQLAlchemy is a Python library for working with databases.
- psycopg2-binary is the PostgreSQL driver for Python.
- Alembic handles database migrations (changing the database structure over time).
Database Connection
app/database.py:
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://taskflow:taskflow@db:5432/taskflow"
)
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
The DATABASE_URL comes from an environment variable. In Docker, the hostname db will point to the PostgreSQL container. We set this up in Docker Tutorial #3: Docker Compose — service names become hostnames.
Database Model
app/models.py:
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from datetime import datetime, timezone
from app.database import Base
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(200), nullable=False)
description = Column(String(1000), default="")
completed = Column(Boolean, default=False)
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)
This is the SQL CREATE TABLE we learned in SQL Tutorial #7: PostgreSQL Setup, expressed in Python. SQLAlchemy converts it to SQL for us.
Request and Response Schemas
app/schemas.py:
from pydantic import BaseModel
from datetime import datetime
class TaskCreate(BaseModel):
title: str
description: str = ""
class TaskResponse(BaseModel):
model_config = {"from_attributes": True}
id: int
title: str
description: str
completed: bool
created_at: datetime
The API
app/main.py:
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import engine, get_db, Base
from app.models import Task
from app.schemas import TaskCreate, TaskResponse
app = FastAPI(title="TaskFlow API")
Base.metadata.create_all(bind=engine)
@app.get("/health")
def health_check():
return {"status": "ok"}
@app.get("/tasks", response_model=list[TaskResponse])
def list_tasks(db: Session = Depends(get_db)):
return db.query(Task).order_by(Task.created_at.desc()).all()
@app.post("/tasks", response_model=TaskResponse, status_code=201)
def create_task(task: TaskCreate, db: Session = Depends(get_db)):
new_task = Task(title=task.title, description=task.description)
db.add(new_task)
db.commit()
db.refresh(new_task)
return new_task
@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
The GET /tasks endpoint uses ORDER BY — we learned this in SQL Tutorial #1: SQL Basics. The POST /tasks endpoint is an INSERT — from SQL Tutorial #2: INSERT, UPDATE, DELETE.
Commit the API Code
git add .
git commit -m "Add FastAPI app with task model and endpoints"
Step 3: SQL Migrations and Seed Data
Database migrations track changes to your database structure in version control. When your team has multiple developers, everyone needs the same database schema. Migrations solve this.
Initialize Alembic:
pip install -r requirements.txt
alembic init alembic
Edit alembic/env.py to use our database connection and models:
# Near the top, add:
from app.database import DATABASE_URL, Base
from app.models import Task # Import so Alembic sees the model
# Replace the sqlalchemy.url line:
config.set_main_option("sqlalchemy.url", DATABASE_URL)
# Set target_metadata:
target_metadata = Base.metadata
Create the first migration. You need a running PostgreSQL instance for --autogenerate to compare the models against the database. If you already have PostgreSQL installed locally, point DATABASE_URL to it. Otherwise, skip ahead to Step 4 to start the Docker containers first, then come back here:
alembic revision --autogenerate -m "create tasks table"
This generates a migration file in alembic/versions/. It contains the SQL to create the tasks table — an upgrade() function that creates the table, and a downgrade() function that drops it.
Seed Data
Create a file scripts/seed.py to populate the database with sample data:
from app.database import SessionLocal
from app.models import Task
def seed():
db = SessionLocal()
tasks = [
Task(title="Set up project", description="Initialize Git repo and project structure", completed=True),
Task(title="Write API endpoints", description="Create CRUD endpoints for tasks", completed=True),
Task(title="Add Docker support", description="Write Dockerfile and docker-compose.yml"),
Task(title="Set up CI/CD", description="Configure GitHub Actions pipeline"),
Task(title="Deploy to production", description="Deploy to VPS with Docker Compose"),
]
db.add_all(tasks)
db.commit()
db.close()
print(f"Seeded {len(tasks)} tasks")
if __name__ == "__main__":
seed()
Commit:
git add alembic.ini alembic/ scripts/
git commit -m "Add Alembic migrations and seed script"
Step 4: Dockerize the Application
Now we package everything into containers. We covered this in Docker Tutorial #2: Dockerfile.
Dockerfile
Create a Dockerfile in the project root:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Key details:
- We copy
requirements.txtfirst to take advantage of Docker layer caching. If your code changes but dependencies do not, Docker reuses the cached layer. - We create a non-root user
appuserfor security. - The health check calls the
/healthendpoint we defined in the API.
Docker Compose
Create docker-compose.yml for local development. This follows the pattern from Docker Tutorial #3: Docker Compose:
services:
app:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://taskflow:taskflow@db:5432/taskflow
depends_on:
db:
condition: service_healthy
volumes:
- .:/app
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: taskflow
POSTGRES_PASSWORD: taskflow
POSTGRES_DB: taskflow
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskflow"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
There are two services:
app— our FastAPI application, built from the Dockerfiledb— a PostgreSQL 16 container
The depends_on with condition: service_healthy makes sure PostgreSQL is ready before the app starts. We covered Docker health checks in Docker Tutorial #5: Deploying with Docker.
The named volume pgdata keeps database data even when the container restarts. We learned about volumes in Docker Tutorial #4: Volumes and Networking.
Add a .dockerignore
.git
.venv
__pycache__
*.pyc
.env
Test It Locally
docker compose up --build
Open another terminal and test the API:
# Health check
curl http://localhost:8000/health
# Create a task
curl -X POST http://localhost:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "My first task", "description": "Testing the API"}'
# List all tasks
curl http://localhost:8000/tasks
You should see your task returned as JSON. The entire stack — API and database — is running in containers.
Commit and Push
git add Dockerfile docker-compose.yml .dockerignore
git commit -m "Add Dockerfile and Docker Compose for local development"
git push -u origin feature/api-setup
Step 5: Create a Pull Request
Now we follow the workflow from Git Tutorial #4: GitHub Workflow. Go to GitHub and create a pull request from feature/api-setup into main.
Write a clear PR description:
## What this PR does
- Adds FastAPI application with task endpoints
- Adds PostgreSQL database with SQLAlchemy
- Adds Alembic migrations
- Adds Dockerfile and Docker Compose
- Adds seed data script
## How to test
1. Run `docker compose up --build`
2. Visit http://localhost:8000/docs for the API docs
3. Create a task with POST /tasks
4. List tasks with GET /tasks
After review, merge the PR into main. Then update your local main:
git switch main
git pull origin main
Step 6: Add CI/CD with GitHub Actions
Create a new branch for the CI/CD pipeline:
git switch -c feature/ci-cd
As we learned in Git Tutorial #4: GitHub Workflow, GitHub Actions lets you run automated tasks on every push or pull request.
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: taskflow
POSTGRES_PASSWORD: taskflow
POSTGRES_DB: taskflow_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U taskflow"
--health-interval 5s
--health-timeout 3s
--health-retries 5
env:
DATABASE_URL: postgresql://taskflow:taskflow@localhost:5432/taskflow_test
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run migrations
run: alembic upgrade head
- name: Run tests
run: pytest tests/ -v
build:
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
This workflow does two things:
- test — Runs on every push and PR. Starts a PostgreSQL service container, installs dependencies, runs migrations, and runs tests.
- build — Runs only on main after tests pass. Builds the Docker image and pushes it to GitHub Container Registry (ghcr.io).
Add a Simple Test
Create tests/test_api.py:
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_create_task():
response = client.post(
"/tasks",
json={"title": "Test task", "description": "A test"}
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "Test task"
assert data["completed"] is False
def test_list_tasks():
# Create a task first
client.post("/tasks", json={"title": "List test"})
response = client.get("/tasks")
assert response.status_code == 200
assert len(response.json()) > 0
def test_get_task_not_found():
response = client.get("/tasks/99999")
assert response.status_code == 404
Commit and push:
git add .github/ tests/
git commit -m "Add GitHub Actions CI/CD and API tests"
git push -u origin feature/ci-cd
Create a PR, wait for the tests to pass, then merge.
Step 7: Deploy to a VPS
This is the final step. You have a tested, containerized application with automated builds. Now let us put it on a server.
Prepare the Server
SSH into your VPS and install Docker:
ssh sam@your-server-ip
# Install Docker (Ubuntu/Debian)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker sam
Log out and back in so the group change takes effect.
Create the Production Compose File
On the server, create a directory for the project:
mkdir -p ~/taskflow
cd ~/taskflow
Create docker-compose.yml:
services:
app:
image: ghcr.io/your-username/taskflow:latest
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@db:5432/${DB_NAME}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_NAME}
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
Notice the differences from the development version:
- Uses
image:instead ofbuild:— the image comes from the registry - Uses
restart: unless-stopped— containers restart after crashes or reboots - Uses environment variables from
.env— no hardcoded secrets - No bind mounts — no source code on the server
Create the .env file on the server:
DB_USER=taskflow
DB_PASS=a-strong-random-password-here
DB_NAME=taskflow
Start the Application
# Log in to GitHub Container Registry
docker login ghcr.io -u your-username
# Start everything
docker compose up -d
Verify it is running:
# Check containers
docker compose ps
# Check logs
docker compose logs app
# Test the API
curl http://localhost:8000/health
Automate Deployment
Add a deploy step to your GitHub Actions workflow. Create .github/workflows/deploy.yml:
name: Deploy
on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd ~/taskflow
docker compose pull
docker compose up -d
docker image prune -f
This workflow runs after the CI workflow succeeds on main. It SSHs into your server, pulls the latest image, and restarts the containers. The docker image prune -f cleans up old images to save disk space.
Add these secrets in your GitHub repository settings:
DEPLOY_HOST— your server IP addressDEPLOY_USER— the SSH user (e.g., sam)DEPLOY_SSH_KEY— the private SSH key
The Complete Workflow
Now that everything is set up, here is what happens every time you work on a new feature:
1. Create branch git switch -c feature/new-endpoint
2. Write code Add files, write tests
3. Commit git add . && git commit -m "Add new endpoint"
4. Push git push -u origin feature/new-endpoint
5. Create PR Open a pull request on GitHub
6. CI runs GitHub Actions runs tests automatically
7. Review Team reviews the code
8. Merge Merge the PR into main
9. Build GitHub Actions builds and pushes Docker image
10. Deploy Server pulls new image and restarts
From writing code to production in minutes. No manual server access needed after the initial setup.
Let us walk through an example. Say Alex wants to add a PUT /tasks/:id endpoint to mark tasks as completed.
Branch
git switch -c feature/complete-task
Code
Add the new endpoint in app/main.py:
@app.put("/tasks/{task_id}", response_model=TaskResponse)
def update_task(task_id: int, completed: bool, 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 = completed
db.commit()
db.refresh(task)
return task
This is an UPDATE ... SET ... WHERE query — what we learned in SQL Tutorial #2: INSERT, UPDATE, DELETE.
Test
Add a test in tests/test_api.py:
def test_complete_task():
# Create a task
response = client.post(
"/tasks",
json={"title": "Task to complete"}
)
task_id = response.json()["id"]
# Mark it as completed
response = client.put(f"/tasks/{task_id}?completed=true")
assert response.status_code == 200
assert response.json()["completed"] is True
Commit, Push, PR
git add .
git commit -m "Add PUT endpoint to mark tasks as completed"
git push -u origin feature/complete-task
Open a PR. GitHub Actions runs the tests. When they pass and the PR is approved, merge it. The build and deploy happen automatically.
Common Mistakes
1. Forgetting to add health checks. Without health checks, depends_on only waits for the container to start — not for the application inside to be ready. Your app might try to connect to PostgreSQL before it accepts connections. Always use condition: service_healthy.
2. Hardcoding secrets in docker-compose.yml. Never put passwords or API keys directly in your Compose file or Dockerfile. Use .env files for local development and GitHub Secrets or environment variables for production. Remember to add .env to .gitignore.
3. Skipping database migrations. It is tempting to let SQLAlchemy auto-create tables with Base.metadata.create_all(). This works for prototypes. But for real projects, use migrations. They let you change the database structure over time, and every developer gets the same schema.
4. Running containers as root. The default user in most Docker images is root. If an attacker breaks into your container, they have root access. Always add a USER instruction in your Dockerfile.
5. Not using specific image tags in production. Using postgres:latest means you might get a new major version when you redeploy. Use postgres:16-alpine to pin the version.
What We Learned
In this capstone article, we tied together everything from the DevTools series:
- Git basics — initialized a repository, wrote
.gitignore(Git #1) - Branching — created feature branches for each change (Git #2)
- GitHub workflow — pushed code, created PRs, set up CI/CD (Git #4)
- Dockerfile — containerized a Python application (Docker #2)
- Docker Compose — ran the app and database together (Docker #3)
- Volumes and networking — persisted data, connected containers (Docker #4)
- Deployment — pushed images to a registry, deployed to a VPS (Docker #5)
- SQL basics — queried and filtered data (SQL #1)
- DML — inserted and updated records (SQL #2)
- PostgreSQL — set up a real database in Docker (SQL #7)
These three tools — Git, Docker, and SQL — form the foundation of modern software development. No matter what language you use or what you build, you will use them every day.
Related Articles
- Git Tutorial #1: Git Basics — your first repository
- Git Tutorial #2: Branching — working on multiple things at once
- Git Tutorial #4: GitHub Workflow — collaborating with others and CI/CD
- Docker Tutorial #2: Dockerfile — building your own images
- Docker Tutorial #3: Docker Compose — running multiple containers
- Docker Tutorial #4: Volumes and Networking — data persistence and communication
- Docker Tutorial #5: Deploying with Docker — from local to production
- SQL Tutorial #1: SQL Basics — SELECT, WHERE, and your first queries
- SQL Tutorial #2: INSERT, UPDATE, DELETE — modifying data
- SQL Tutorial #7: PostgreSQL Setup — a real database for real projects
- Git Cheat Sheet — quick reference for Git commands
- Docker Cheat Sheet — quick reference for Docker commands
- SQL Cheat Sheet — quick reference for SQL queries
What’s Next?
Congratulations! You have completed the entire DevTools series. You now have practical knowledge of the three tools every developer uses daily.
Here are some ways to keep learning:
- Add features to TaskFlow. Try adding user authentication, pagination, or filtering by completed status.
- Add a reverse proxy. Put Nginx in front of your API for HTTPS and load balancing.
- Explore other databases. Try the SQL concepts you learned with MySQL or SQLite.
- Check out our other tutorials. We have series on Jetpack Compose and Kotlin Multiplatform that also use Git and Docker in real projects.