PREVIEW · prometheus case study, new drawing← all roundsround 3current homeoption B homedrawings before/afterGFGNivaranPrometheusevery releasehow it's builtphoto 1photo 2photo 3link image

← 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

The code

A lean, non-root container prometheus · Dockerfiledependencies in their own cached layer, and a non-root user
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"]
show all 38 linesshow less
Release on tag prometheus · .github/workflows/release.ymla version tag triggers the build and push to GHCR
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 }}
show all 69 linesshow less
Metrics and errors wired in prometheus · app/main.pyPrometheus metrics wired into the app; Sentry starts when a DSN is configured
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
show all 54 linesshow less

Results

Source on GitHub