← Every release

Prometheus: Expense Tracker API

I wanted one small API with the full production path around it: tests on every push, a versioned Docker image on every tag, metrics, and error tracking.

How it works

render.yaml: Render builds thisgit pushto mainCIruff · pytest · Postgres testsCodeQLsecurity scangit tag v*releasedocker builduv, non-rootsmoke testGET /healthGHCR:v1.x + :latestFastAPI appauth · transactionsPostgreSQLasync SQLAlchemy/metricsPrometheus formatSentrywhen a DSN is setBudget checkActions cron · daily 09:00same Dockerfile runs the appover limit? alertevery tag is a deployable image

Key decisions

01 A lean, non-root container

The image should rebuild fast and run safely. Dependencies install in their own layer, so a code change does not reinstall everything, and the app runs as a non-root user.

See the codeHide the code prometheus · Dockerfile · 38 lines
FROM python:3.12-slim AS base

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /app

# Set environment variables for uv
ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    UV_CACHE_DIR=/opt/uv-cache

# Copy dependency files first for better layer caching
COPY pyproject.toml uv.lock ./

# Install dependencies (without project for faster rebuilds)
RUN uv sync --frozen --no-install-project

# Copy application code
COPY . .

# Install the project itself
RUN uv sync --frozen

# Create non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser
RUN chown -R appuser:appuser /app
USER appuser

# Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

# Expose port
EXPOSE 8000

# Run the application
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View on GitHub ↗

02 Release on tag

A release should be one command: push a version tag. The workflow builds the image, starts it and checks /health, and only then pushes it to GHCR and creates the GitHub Release. A broken image never gets published.

See the codeHide the code prometheus · .github/workflows/release.yml · 69 lines
name: Release
on:
  push:
    tags: ['v*']        # triggers on v0.1.0 etc.
  workflow_dispatch:     # allow manual test runs

permissions:
  contents: write
  packages: write

concurrency:
  group: release-${{ github.ref }}
  cancel-in-progress: false

jobs:
  build-and-push:
    # simple and template-friendly: run on any tag
    if: ${{ startsWith(github.ref, 'refs/tags/') }}
    runs-on: ubuntu-latest
    env:
      # optional; empty when not set, safe for 'if:' checks
      SENTRY_DSN: ${{ secrets.SENTRY_DSN || '' }}

    steps:
      - uses: actions/checkout@v4

      - name: Compute image name (lowercase)
        id: img
        run: |
          REPO_LC=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
          echo "image=ghcr.io/${REPO_LC}" >> "$GITHUB_OUTPUT"

      - name: Log in to GHCR
        run: echo "${{ github.token }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin

      - name: Build
        run: |
          docker build \
            -t ${{ steps.img.outputs.image }}:${{ github.ref_name }} \
            -t ${{ steps.img.outputs.image }}:latest .

      - name: Smoke test container health
        run: |
          cid=$(docker run -d -p 8000:8000 ${{ steps.img.outputs.image }}:latest)
          for i in {1..30}; do curl -fsS http://localhost:8000/health && break || sleep 1; done
          docker logs "$cid" --tail 50
          docker rm -f "$cid"

      - name: Push
        run: |
          docker push ${{ steps.img.outputs.image }}:${{ github.ref_name }}
          docker push ${{ steps.img.outputs.image }}:latest

      - name: Sentry release (optional)
        if: ${{ env.SENTRY_DSN != '' }}
        run: echo "Would run sentry-cli here for ${{ github.ref_name }}"

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          tag_name: ${{ github.ref_name }}
          name: Release ${{ github.ref_name }}
          body: |
            See CHANGELOG.md for details.
            Image:
            - ${{ steps.img.outputs.image }}:latest
            - ${{ steps.img.outputs.image }}:${{ github.ref_name }}
        env:
          GITHUB_TOKEN: ${{ github.token }}
View on GitHub ↗

03 Metrics and errors wired in

When something breaks in production, I want to know before a user tells me. The app exposes Prometheus metrics at /metrics, and Sentry starts only when a DSN is set, so local runs need no setup.

See the codeHide the code prometheus · app/main.py · 54 lines
import logging
from contextlib import asynccontextmanager

import sentry_sdk
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.logging import LoggingIntegration

from app.api.routes import analytics, auth, categories, health, transactions
from app.config import settings
from app.core.database import init_database
from app.core.metrics import setup_metrics

# … cut: logging setup

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Application lifespan events."""
    # Startup
    logger.info(f"Starting {settings.app_name} v{settings.app_version}")

    # Initialize Sentry if DSN is provided
    if settings.sentry_dsn:
        sentry_sdk.init(
            dsn=settings.sentry_dsn,
            integrations=[
                FastApiIntegration(auto_enabling_instrumentations=False),
                LoggingIntegration(level=logging.INFO),
            ],
            traces_sample_rate=0.1,
            environment=settings.environment,
        )
        logger.info("Sentry initialized")

    # Initialize database
    await init_database()

    logger.info("Application startup complete")

    yield

    # Shutdown
    logger.info("Shutting down application")


# … cut: app creation, CORS and request logging

# Setup metrics
setup_metrics(app)
logger.info("Prometheus metrics enabled")

# … cut: routers
View on GitHub ↗

What I'd do differently

Results

Source on GitHub