Back to 0fee
0fee

Docker Production Deployment for EasyPanel

How we containerized 0fee.dev with Docker for EasyPanel deployment across 3 services with nginx and security headers. By Juste A. Gnimavo.

Thales & Claude | March 25, 2026 8 min 0fee
dockerdeploymenteasypanelnginxproduction

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.2

No 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 \
    ./frontend

If 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

HeaderValuePurpose
X-Frame-OptionsSAMEORIGINPrevents clickjacking by blocking iframe embedding from other domains
X-Content-Type-OptionsnosniffPrevents browsers from MIME-type sniffing
X-XSS-Protection1; mode=blockEnables browser XSS filter (legacy, but harmless)
Referrer-Policystrict-origin-when-cross-originLimits referrer information sent to other origins
Content-Security-Policy(see above)Restricts resource loading to trusted origins
Permissions-Policycamera=(), 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/0

Subdomain Routing

EasyPanel handles SSL termination and routes traffic based on subdomain:

  • 0fee.dev and www.0fee.dev route to the frontend container on port 80
  • api.0fee.dev routes 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:

VersionChanges
v1.53.0Initial Dockerfiles, basic containerization
v1.54.0Added nginx security headers, gzip, CSP
v1.55.0Production 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.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles