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 tasks
  • POST /tasks — create a new task
  • GET /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.txt first 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 appuser for security.
  • The health check calls the /health endpoint 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 Dockerfile
  • db — 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:

  1. test — Runs on every push and PR. Starts a PostgreSQL service container, installs dependencies, runs migrations, and runs tests.
  2. 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 of build: — 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 address
  • DEPLOY_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.

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.