The Development Environment: Homelab, Cloudflare Tunnel, Docker Compose, and a Local Stack That Mirrors Production
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
| Component | Local 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.