Skip to main content
ship before you scale

The Development Environment: Homelab, Cloudflare Tunnel, Docker Compose, and a Local Stack That Mirrors Production

7 min read Chapter 4 of 42

The Development Environment

A development environment that does not match production is a development environment that lies to you. The bug that does not reproduce locally, the feature that works on your machine but fails in staging, the configuration that exists in development but not in production: these are symptoms of a development environment built for convenience instead of accuracy.

Marketflow’s development environment runs the same services as production: PostgreSQL, Redis, a FastAPI backend, and a React frontend. Docker Compose orchestrates them. Cloudflare Tunnel provides HTTPS access without configuring certificates or opening firewall ports. Stripe CLI forwards webhook events to the local backend. The gap between “it works locally” and “it works in production” is as small as it can be.

The Feature

A developer clones the Marketflow repository, runs docker compose up, and has a working development environment with a PostgreSQL database, a Redis instance, a FastAPI backend with hot reload, and a React frontend with hot module replacement. The environment is accessible over HTTPS through a Cloudflare Tunnel, and Stripe webhook events are forwarded to the local backend for testing.

The Decision

Docker Compose over individual service installations. Installing PostgreSQL, Redis, and Python on the host machine creates a “works on my machine” problem. Docker Compose defines the environment declaratively. Every developer gets the same versions, the same configuration, the same network topology. The overhead of Docker is minimal on Linux and acceptable on macOS with Docker Desktop or OrbStack.

Cloudflare Tunnel over ngrok or localtunnel. Cloudflare Tunnel is free, does not rate-limit, provides a stable subdomain, and integrates with Cloudflare’s DNS and security features. Ngrok’s free tier rotates URLs on every restart, which breaks webhook configurations and OAuth callbacks. Cloudflare Tunnel provides a permanent subdomain (dev.marketflow.app) that points to the local machine.

uv over pip or poetry for Python dependency management. uv resolves and installs dependencies in seconds, not minutes. It is a drop-in replacement for pip with a lockfile. Poetry is slower and adds complexity that provides no benefit for this project.

Vite over Create React App or Next.js. Create React App is unmaintained. Next.js adds server-side rendering complexity that Marketflow does not need. Vite provides fast HMR, TypeScript support, and a minimal configuration surface.

The Implementation

Repository Structure

marketflow/
├── backend/
│   ├── alembic/
│   │   ├── versions/
│   │   └── env.py
│   ├── app/
│   │   ├── __init__.py
│   │   ├── main.py
│   │   ├── config.py
│   │   ├── database.py
│   │   ├── dependencies.py
│   │   ├── models/
│   │   │   ├── __init__.py
│   │   │   └── base.py
│   │   ├── schemas/
│   │   │   └── __init__.py
│   │   ├── routers/
│   │   │   └── __init__.py
│   │   └── services/
│   │       └── __init__.py
│   ├── tests/
│   │   ├── conftest.py
│   │   └── __init__.py
│   ├── pyproject.toml
│   ├── alembic.ini
│   └── Dockerfile
├── frontend/
│   ├── src/
│   │   ├── api/
│   │   ├── components/
│   │   │   └── ui/
│   │   ├── hooks/
│   │   ├── lib/
│   │   ├── pages/
│   │   ├── App.tsx
│   │   └── main.tsx
│   ├── package.json
│   ├── tsconfig.json
│   ├── vite.config.ts
│   └── Dockerfile
├── docker-compose.yml
├── docker-compose.prod.yml
├── .env.example
├── .github/
│   └── workflows/
│       └── deploy.yml
└── README.md

This structure separates backend and frontend into independent directories, each with their own Dockerfile. Docker Compose ties them together. The app/ directory inside backend/ follows a flat module structure: models, schemas, routers, services. No nested packages, no “clean architecture” folder hierarchies. Flat is better than nested when the project has fewer than 50 files.

Docker Compose

# docker-compose.yml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: marketflow
      POSTGRES_PASSWORD: localdev
      POSTGRES_DB: marketflow
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U marketflow"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    command: uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
    ports:
      - "8000:8000"
    volumes:
      - ./backend:/app
    environment:
      DATABASE_URL: postgresql+asyncpg://marketflow:localdev@db:5432/marketflow
      REDIS_URL: redis://redis:6379/0
      SUPABASE_URL: ${SUPABASE_URL}
      SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY}
      SUPABASE_JWT_SECRET: ${SUPABASE_JWT_SECRET}
      STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
      STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
      RESEND_API_KEY: ${RESEND_API_KEY}
      ENVIRONMENT: development
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    command: npm run dev -- --host 0.0.0.0
    ports:
      - "5173:5173"
    volumes:
      - ./frontend:/app
      - /app/node_modules
    environment:
      VITE_API_URL: http://localhost:8000
      VITE_SUPABASE_URL: ${SUPABASE_URL}
      VITE_SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}

  stripe-cli:
    image: stripe/stripe-cli:latest
    command: listen --forward-to backend:8000/api/webhooks/stripe --api-key ${STRIPE_SECRET_KEY}
    depends_on:
      - backend

volumes:
  pgdata:

The Stripe CLI container forwards webhook events to the backend automatically. No manual stripe listen commands. When the developer triggers a test event in the Stripe dashboard, it reaches the local backend within seconds.

FastAPI Project Setup

# backend/pyproject.toml
[project]
name = "marketflow-backend"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "fastapi>=0.115.0",
    "uvicorn[standard]>=0.30.0",
    "sqlalchemy[asyncio]>=2.0.30",
    "asyncpg>=0.29.0",
    "alembic>=1.13.0",
    "pydantic>=2.7.0",
    "pydantic-settings>=2.3.0",
    "python-jose[cryptography]>=3.3.0",
    "httpx>=0.27.0",
    "stripe>=10.0.0",
    "resend>=2.0.0",
    "redis[hiredis]>=5.0.0",
    "python-multipart>=0.0.9",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.2.0",
    "pytest-asyncio>=0.23.0",
    "httpx>=0.27.0",
    "ruff>=0.5.0",
]
# backend/app/config.py
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    database_url: str
    redis_url: str = "redis://localhost:6379/0"
    supabase_url: str
    supabase_service_key: str
    supabase_jwt_secret: str
    stripe_secret_key: str
    stripe_webhook_secret: str
    resend_api_key: str
    environment: str = "development"

    @property
    def is_development(self) -> bool:
        return self.environment == "development"

    model_config = {"env_file": ".env"}


settings = Settings()
# backend/app/database.py
from sqlalchemy.ext.asyncio import (
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)

from app.config import settings

engine = create_async_engine(
    settings.database_url,
    echo=settings.is_development,
    pool_size=5,
    max_overflow=10,
)

async_session = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,
)


async def get_db() -> AsyncSession:
    async with async_session() as session:
        try:
            yield session
        except Exception:
            await session.rollback()
            raise
# backend/app/main.py
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.config import settings


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup
    yield
    # Shutdown


app = FastAPI(
    title="Marketflow API",
    lifespan=lifespan,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:5173",
        "https://marketflow.app",
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/health")
async def health_check() -> dict[str, str]:
    return {"status": "healthy"}

Cloudflare Tunnel

Cloudflare Tunnel creates a persistent, encrypted connection between the local machine and Cloudflare’s edge network. Traffic flows from the internet to Cloudflare, through the tunnel, to the local machine. No port forwarding. No static IP. No self-signed certificates.

# Install cloudflared
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
  -o /usr/local/bin/cloudflared
chmod +x /usr/local/bin/cloudflared

# Authenticate with Cloudflare
cloudflared tunnel login

# Create a tunnel named "marketflow-dev"
cloudflared tunnel create marketflow-dev

# Configure the tunnel
cat > ~/.cloudflared/config.yml << 'EOF'
tunnel: <TUNNEL_ID>
credentials-file: /home/dev/.cloudflared/<TUNNEL_ID>.json

ingress:
  - hostname: api-dev.marketflow.app
    service: http://localhost:8000
  - hostname: dev.marketflow.app
    service: http://localhost:5173
  - service: http_status:404
EOF

# Add DNS records (run once)
cloudflared tunnel route dns marketflow-dev api-dev.marketflow.app
cloudflared tunnel route dns marketflow-dev dev.marketflow.app

# Start the tunnel
cloudflared tunnel run marketflow-dev

After this setup, https://dev.marketflow.app serves the React frontend and https://api-dev.marketflow.app serves the FastAPI backend, both with valid HTTPS certificates managed by Cloudflare. The tunnel survives machine restarts if configured as a systemd service.

Environment Variables

# .env.example
# Supabase (create a project at supabase.com)
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_KEY=eyJ...
SUPABASE_JWT_SECRET=your-jwt-secret

# Stripe (test mode keys from dashboard.stripe.com)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

# Resend (api key from resend.com)
RESEND_API_KEY=re_...

# These are set in docker-compose.yml, listed here for reference
# DATABASE_URL=postgresql+asyncpg://marketflow:localdev@db:5432/marketflow
# REDIS_URL=redis://redis:6379/0

Copy .env.example to .env and fill in the values. The Docker Compose file references these variables. Every service that requires an API key reads it from the environment, never from a hardcoded string.

The Trap

# TRAP: Hardcoded secrets in docker-compose.yml
services:
  backend:
    environment:
      STRIPE_SECRET_KEY: sk_test_51ABC123...
      SUPABASE_SERVICE_KEY: eyJhbGciOiJIUzI1NiIs...
# SAFE: Secrets read from .env file, never committed
services:
  backend:
    environment:
      STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
      SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY}

Add .env to .gitignore before the first commit. Commit .env.example with placeholder values. This is a mistake that cannot be undone once the secret is in git history. Rotating a Stripe API key is annoying. Rotating a Supabase service key requires updating every deployment.

The Cost

ComponentLocal Development Cost
Docker Desktop / OrbStack$0 (free for personal use)
Cloudflare Tunnel$0 (free tier)
Stripe CLI$0 (free)
Supabase project (free tier)$0
Hetzner VPS for homelab (optional)€4.51/month

The development environment costs nothing if running on the developer’s own machine. If using a homelab server (a dedicated Linux machine or an old laptop), the only cost is electricity. The Hetzner VPS listed above is for developers who prefer a remote development server over a local one.