Building a payment platform is one thing. Getting it to production is another. In Session 086, we containerized 0fee.dev for deployment on EasyPanel -- a self-hosted PaaS that runs on Docker. Three Dockerfiles (backend, frontend, worker), a production-only requirements file, an nginx configuration with security headers and gzip compression, and subdomain routing that maps api.0fee.dev to the backend and 0fee.dev to the frontend.
This session also produced the v1.53.0 through v1.55.0 releases, making it one of the final sessions before the platform went live.
The Three-Service Architecture
0fee.dev in production runs as three Docker services:
+-------------------+ +-------------------+ +-------------------+
| Frontend | | Backend | | Worker |
| (SolidJS + | | (FastAPI + | | (Celery + |
| nginx) | | Uvicorn) | | Redis) |
| | | | | |
| 0fee.dev | | api.0fee.dev | | (internal) |
| Port 80/443 | | Port 8000 | | No public port |
+-------------------+ +-------------------+ +-------------------+
| | |
+------------ EasyPanel Docker Host ---------------+The frontend serves the SolidJS application through nginx. The backend runs FastAPI with Uvicorn. The worker runs Celery for background tasks (webhook delivery, invoice generation, email sending). Only the frontend and backend are publicly accessible.
Backend Dockerfile
dockerfile# backend/Dockerfile
FROM python:3.12-slim AS base
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies (production only)
COPY requirements-prod.txt .
RUN pip install --no-cache-dir -r requirements-prod.txt
# Copy application code
COPY . .
# Create non-root user
RUN useradd --create-home appuser
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:8000/health')" || exit 1
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]Key decisions:
python:3.12-slim over python:3.12-alpine. Alpine uses musl libc instead of glibc, which causes compatibility issues with some Python packages -- particularly psycopg2 and cryptography. The slim image adds ~50MB but eliminates hours of debugging.
requirements-prod.txt without dev tools. The production requirements file excludes pytest, black, ruff, mypy, and other development dependencies. This reduces the image size by ~200MB and the attack surface.
Non-root user. The appuser runs the application. Even if the container is compromised, the attacker does not have root privileges.
4 Uvicorn workers. Each worker handles requests independently. On a 2-core EasyPanel instance, 4 workers (2x CPU cores) provides good throughput without oversubscription.
The Production Requirements File
text# requirements-prod.txt
fastapi==0.109.2
uvicorn[standard]==0.27.1
sqlalchemy[asyncio]==2.0.25
asyncpg==0.29.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
httpx==0.26.0
celery[redis]==5.3.6
python-multipart==0.0.6
pydantic==2.5.3
pydantic-settings==2.1.0
sqladmin==0.16.1
weasyprint==60.2
Pillow==10.2.0
jinja2==3.1.3
python-dotenv==1.0.1
cryptography==42.0.2No pytest, no black, no ruff, no ipdb. Production images should contain only what production needs.
Frontend Dockerfile
The frontend Dockerfile is a multi-stage build: SolidJS builds to static files, then nginx serves them:
dockerfile# frontend/Dockerfile
FROM node:20-slim AS builder
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json ./
RUN npm ci --production=false
# Build argument for API URL
ARG VITE_API_URL=https://api.0fee.dev
ENV VITE_API_URL=$VITE_API_URL
# Copy source and build
COPY . .
RUN npm run build
# Production stage: nginx serves static files
FROM nginx:1.25-alpine AS production
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Health check
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost/ || exit 1
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]The critical detail is VITE_API_URL. Vite embeds environment variables at build time, not runtime. This means the API URL must be known when the Docker image is built:
bash# Build with production API URL
docker build \
--build-arg VITE_API_URL=https://api.0fee.dev \
-t 0fee-frontend:v1.55.0 \
./frontendIf you need different API URLs for staging and production, you build separate images. This is a Vite constraint, not a Docker one.
Worker Dockerfile
dockerfile# worker/Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements-prod.txt .
RUN pip install --no-cache-dir -r requirements-prod.txt
COPY . .
RUN useradd --create-home celeryuser
USER celeryuser
CMD ["celery", "-A", "tasks.celery_app", "worker", \
"--loglevel=info", "--concurrency=2", \
"--max-tasks-per-child=100"]The --max-tasks-per-child=100 flag restarts worker processes after 100 tasks. This prevents memory leaks from accumulating in long-running worker processes -- a common issue with WeasyPrint (PDF generation) which can leak memory on complex documents.
The nginx Configuration
nginx# frontend/nginx.conf
server {
listen 80;
server_name 0fee.dev www.0fee.dev;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff2;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://appleid.cdn-apple.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.0fee.dev; frame-src 'self' https://appleid.apple.com;" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Cache static assets aggressively
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback: serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Deny access to hidden files
location ~ /\. {
deny all;
return 404;
}
}Security Headers Explained
| Header | Value | Purpose |
|---|---|---|
X-Frame-Options | SAMEORIGIN | Prevents clickjacking by blocking iframe embedding from other domains |
X-Content-Type-Options | nosniff | Prevents browsers from MIME-type sniffing |
X-XSS-Protection | 1; mode=block | Enables browser XSS filter (legacy, but harmless) |
Referrer-Policy | strict-origin-when-cross-origin | Limits referrer information sent to other origins |
Content-Security-Policy | (see above) | Restricts resource loading to trusted origins |
Permissions-Policy | camera=(), microphone=(), geolocation=() | Disables browser APIs the site does not need |
The Content-Security-Policy is the most carefully crafted header. It allows:
- Scripts from self and inline (needed for SolidJS hydration) plus Apple's JS SDK
- Styles from self, inline, and Google Fonts
- Fonts from self and Google Fonts static
- Images from self, data URIs, and any HTTPS source
- API connections to api.0fee.dev only
- Frames from self and Apple (for Apple Sign In popup)
Gzip Compression
The gzip_comp_level 6 setting is a deliberate compromise between compression ratio and CPU usage. Level 6 achieves ~90% of the maximum compression (level 9) at ~50% of the CPU cost. For a SolidJS bundle, this typically reduces transfer size from ~300KB to ~90KB.
EasyPanel Service Configuration
EasyPanel manages Docker containers through a web interface. The three 0fee.dev services are configured as:
yaml# Conceptual EasyPanel configuration
services:
- name: 0fee-frontend
image: 0fee-frontend:v1.55.0
domains:
- host: 0fee.dev
port: 80
- host: www.0fee.dev
port: 80
replicas: 1
resources:
cpu: 0.5
memory: 256M
- name: 0fee-backend
image: 0fee-backend:v1.55.0
domains:
- host: api.0fee.dev
port: 8000
replicas: 1
resources:
cpu: 1.0
memory: 512M
env:
- DATABASE_URL=postgresql+asyncpg://...
- REDIS_URL=redis://redis:6379/0
- SECRET_KEY=${SECRET_KEY}
- ENVIRONMENT=production
- name: 0fee-worker
image: 0fee-worker:v1.55.0
domains: [] # No public access
replicas: 1
resources:
cpu: 0.5
memory: 512M
env:
- DATABASE_URL=postgresql+asyncpg://...
- REDIS_URL=redis://redis:6379/0Subdomain Routing
EasyPanel handles SSL termination and routes traffic based on subdomain:
0fee.devandwww.0fee.devroute to the frontend container on port 80api.0fee.devroutes to the backend container on port 8000
HTTPS is handled by EasyPanel's built-in Let's Encrypt integration. The Docker containers only need to handle HTTP.
Version Tagging: v1.53.0 Through v1.55.0
The containerization session produced three releases:
| Version | Changes |
|---|---|
| v1.53.0 | Initial Dockerfiles, basic containerization |
| v1.54.0 | Added nginx security headers, gzip, CSP |
| v1.55.0 | Production requirements, health checks, worker tuning |
Each version was tagged in git and built as a separate Docker image. EasyPanel can roll back to any previous version by changing the image tag -- a critical capability for a payment platform where bad deployments need instant rollback.
What We Learned
Multi-stage builds are essential for frontends. The Node.js builder stage is ~1.2GB. The final nginx stage is ~40MB. Without multi-stage builds, you ship a gigabyte of build tools to production.
VITE_API_URL must be a build argument. This caught us off guard. Vite replaces import.meta.env.VITE_API_URL at build time with a literal string. There is no way to inject it at container startup without a custom entrypoint that rewrites the built JavaScript -- a fragile approach. Build-time injection is the intended pattern.
requirements-prod.txt is not optional. Development dependencies add hundreds of megabytes and introduce unnecessary packages into the production environment. Maintain a separate file.
Content-Security-Policy is hard to get right. Our first CSP blocked Apple Sign In, Google Fonts, and the API connection. It took three iterations to get a policy that was strict enough to be meaningful but permissive enough for the application to function.
Worker memory management requires max-tasks-per-child. Without it, Celery workers grow unbounded in memory. With WeasyPrint generating PDFs, a worker can consume 500MB+ if not periodically recycled.
This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.