Build a URL Shortener from Scratch — Part 5: Analytics, Docker, and Deployment

This final part covers analytics tracking, Dockerization, and production deployment.

Analytics: Tracking Clicks

Log each click with IP geolocation for insights:

# app/services/analytics.py
import requests
from app.models import Click
from app.core.cache import redis_client

def record_click(url_id: int, request_headers: dict, client_ip: str):
    """Record a click with referrer and country data."""
    country = get_country_from_ip(client_ip)
    referrer = request_headers.get("referer", "")

    # Write to Redis for async processing
    redis_client.lpush("click_queue", f"{url_id}|{referrer}|{country}|{client_ip}")

def get_country_from_ip(ip: str) -> str:
    """Lookup country from IP using a free geo IP service."""
    try:
        resp = requests.get(f"http://ip-api.com/json/{ip}?fields=countryCode", timeout=2)
        return resp.json().get("countryCode", "XX")
    except Exception:
        return "XX"

def process_click_queue(db_session):
    """Batch process clicks from Redis to PostgreSQL."""
    while True:
        raw = redis_client.rpop("click_queue")
        if not raw:
            break
        url_id, referrer, country, _ = raw.split("|")
        click = Click(url_id=int(url_id), referrer=referrer, country=country)
        db_session.add(click)
    db_session.commit()

Analytics API

@router.get("/stats/{code}")
def get_url_stats(code: str, db: Session = Depends(get_db)):
    url = db.query(URL).filter(URL.short_code == code).first()
    if not url:
        raise HTTPException(404)

    # Get daily clicks for last 30 days
    from sqlalchemy import func
    thirty_days_ago = datetime.now(timezone.utc) - timedelta(days=30)

    daily = (
        db.query(
            func.date(Click.clicked_at).label("date"),
            func.count().label("clicks")
        )
        .filter(Click.url_id == url.id, Click.clicked_at >= thirty_days_ago)
        .group_by(func.date(Click.clicked_at))
        .order_by("date")
        .all()
    )

    top_referrers = (
        db.query(Click.referrer, func.count().label("count"))
        .filter(Click.url_id == url.id)
        .group_by(Click.referrer)
        .order_by(func.count().desc())
        .limit(5)
        .all()
    )

    return {
        "short_code": code,
        "original_url": url.original_url,
        "total_clicks": url.click_count,
        "daily_clicks": [{"date": str(d), "clicks": c} for d, c in daily],
        "top_referrers": [{"referrer": r, "count": c} for r, c in top_referrers]
    }

Dockerfile

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

docker-compose.yml (Production)

version: '3.8'

services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql://postgres:password@db:5432/url_shortener
      REDIS_URL: redis://redis:6379
      BASE_URL: https://short.ly
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      retries: 3

  db:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_DB: url_shortener
    healthcheck:
      test: ["CMD-SHELL", "pg_isready"]
      interval: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

volumes:
  pgdata:
  redis_data:

Deployment

# Build and start
docker-compose up -d --build

# Run migrations
docker-compose exec api alembic upgrade head

# Verify
curl -X POST https://short.ly/shorten \
  -d '{"url": "https://example.com"}'
# {"short_code":"q0Z","short_url":"https://short.ly/q0Z",...}

Full Series Summary

We built a production-ready URL shortener with:

  • Base62 encoding for compact, URL-safe short codes
  • PostgreSQL for durable storage with proper indexing
  • FastAPI with dependency injection for clean, testable code
  • Redis caching for sub-millisecond redirects
  • Click analytics with IP geolocation and referrer tracking
  • Docker with multi-service orchestration
  • Background processing via Redis queues for analytics writes

The complete source code and instructions are available at the project repository.

← Part 4


Advertisement