Deployment

Deploy Corral with any stack β€” Docker, Railway, Fly.io, Vercel, or bare VMs. The auth server runs as a lightweight sidecar: internal only, never exposed to the internet.

CLI-first deploy flow (v0.4.1)
Generate platform configs with corral deploy docker|fly|railway|render, then deploy with your platform CLI.
bash
corral deploy docker
corral deploy fly
corral deploy railway
corral deploy render

The "Nobody Sees Node" Principle

Corral's auth server is a small Node.js process (~40 lines of Express) that runs internally on port 3456. Your backend is the only thing that talks to it. The outside world only sees your backend.

Internet
  β”‚
  β–Ό
:8000  Your backend  (exposed)
  β”‚
  β–Ό AUTH_URL=http://localhost:3456
:3456  Corral auth   (internal only)

This means: no extra domain, no separate auth service, no CORS wrangling. Your backend validates sessions by calling http://localhost:3456/api/auth/validate. Python, Go, Rust, Ruby β€” it doesn't matter. One HTTP call.

For Next.js / Node-only apps
If your entire app is Node.js (Next.js, Express, Hono, Fastify), Corral mounts directly as route handlers β€” no sidecar needed. The supervisord pattern is for non-Node backends that need Corral's auth alongside them.

Docker

The single-container pattern runs both processes (auth + backend) via supervisord. Only the backend port is exposed. tini handles PID 1 signal forwarding correctly.

Dockerfile

Run corral init --docker to generate this β€” or use it as a starting point. The template uses build args to switch between Python, Go, Rust, and Ruby backends.

Dockerfile dockerfile
# ============================================================
# Corral Auth + Your Backend β€” Single Container
# Auth server on :3456 (internal). Backend on :8000 (exposed).
# ============================================================

FROM node:22-slim AS base

RUN apt-get update && apt-get install -y --no-install-recommends \
    supervisor curl tini \
    && rm -rf /var/lib/apt/lists/*

# ── Stage 2: Corral auth server (Node) ──────────────────────
COPY server/package*.json server/
RUN cd server && npm ci --omit=dev
COPY server/ server/

# ── Stage 3: Your backend ───────────────────────────────────
# Python example β€” replace with Go/Rust/Ruby as needed
COPY requirements.txt .
RUN python3 -m venv /app/venv \
    && /app/venv/bin/pip install --no-cache-dir -r requirements.txt
COPY app/ app/

# ── Stage 4: Runtime config ─────────────────────────────────
RUN mkdir -p /app/data
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf

# Non-root user
RUN groupadd -r corral && useradd -r -g corral -d /app corral \
    && chown -R corral:corral /app
USER corral

ENV AUTH_PORT=3456 \
    AUTH_URL=http://localhost:3456 \
    PORT=8000 \
    CORRAL_DB_PATH=/app/data/corral.db \
    NODE_ENV=production

# Only expose the backend β€” auth stays internal
EXPOSE 8000

ENTRYPOINT ["tini", "--"]
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

supervisord.conf

Two processes, one container. Both auto-restart on failure. Logs stream to stdout/stderr for docker logs compatibility.

supervisord.conf ini
; Two processes, one container. Auth on :3456 (internal), backend on :8000.

[supervisord]
nodaemon=true
user=corral
logfile=/dev/null
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid

; ── Corral Auth Server (Node.js) ─────────────────────────────
[program:auth]
command=node /app/server/auth.js
directory=/app/server
environment=PORT="3456",AUTH_PORT="3456",CORRAL_DB_PATH="/app/data/corral.db"
autorestart=true
startretries=5
startsecs=3
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
priority=10

; ── Backend (Python example) ─────────────────────────────────
[program:backend]
command=/app/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
directory=/app
environment=PORT="8000",AUTH_URL="http://localhost:3456",CORRAL_DB_PATH="/app/data/corral.db"
autorestart=true
startretries=5
startsecs=3
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
priority=20

Build and run

Docker
docker build -t myapp . docker run \ -p 8000:8000 \ -v corral_data:/app/data \ --env-file .env \ myapp # Test the health endpoint curl http://localhost:8000/health {"status":"ok","auth":"connected"}

Environment variables for Docker

Create a .env file (never commit this):

.env bash
# Required
BETTER_AUTH_SECRET=your-32-char-random-string-here
DATABASE_URL=postgresql://user:pass@host:5432/db

# Stripe (required for billing)
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

# Social auth (optional)
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...

# Internal (set automatically by Dockerfile defaults)
AUTH_PORT=3456
AUTH_URL=http://localhost:3456
PORT=8000
CORRAL_DB_PATH=/app/data/corral.db

Railway

Railway builds from your Dockerfile automatically. Push your repo and Railway detects the railway.json config. SQLite works on Railway with a persistent volume β€” or swap in PostgreSQL from the Railway dashboard.

railway.json

railway.json json
{
  "$schema": "https://railway.app/railway.schema.json",
  "build": {
    "builder": "DOCKERFILE",
    "dockerfilePath": "Dockerfile"
  },
  "deploy": {
    "startCommand": "supervisord -c /etc/supervisor/conf.d/supervisord.conf",
    "healthcheckPath": "/health",
    "healthcheckTimeout": 5,
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 3
  },
  "environments": {
    "production": {
      "variables": {
        "AUTH_PORT": "3456",
        "AUTH_URL": "http://localhost:3456",
        "CORRAL_DB_PATH": "/app/data/corral.db"
      }
    }
  }
}

Deploy to Railway

Railway
railway login railway init railway up Deployed to https://myapp-production.up.railway.app

Environment variables in Railway dashboard

Go to your service β†’ Variables tab. Add these:

VariableValue
BETTER_AUTH_SECRETGenerate with openssl rand -base64 32
STRIPE_SECRET_KEYYour Stripe live/test secret key
STRIPE_WEBHOOK_SECRETFrom Stripe webhook dashboard
DATABASE_URLRailway PostgreSQL plugin provides this automatically
GOOGLE_CLIENT_IDFrom Google Cloud Console
GOOGLE_CLIENT_SECRETFrom Google Cloud Console
PostgreSQL on Railway
Add a PostgreSQL plugin from the Railway dashboard. It auto-injects DATABASE_URL into your service. Corral will use it automatically when you set database.adapter: postgres in corral.yaml.

Fly.io

Fly.io is ideal for Corral: persistent volumes for SQLite, global edge deployment, and first-class Docker support. Use fly launch to get started β€” it detects your Dockerfile automatically.

fly.toml

fly.toml toml
# Fly.io config β€” single container, auth on :3456 (internal)
app = "your-app-name"
primary_region = "iad"

[build]
  dockerfile = "Dockerfile"

[env]
  AUTH_PORT = "3456"
  AUTH_URL = "http://localhost:3456"
  PORT = "8000"
  CORRAL_DB_PATH = "/app/data/corral.db"

[http_service]
  internal_port = 8000
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 0

  [http_service.concurrency]
    type = "connections"
    hard_limit = 250
    soft_limit = 200

[[http_service.checks]]
  grace_period = "10s"
  interval = "30s"
  method = "GET"
  path = "/health"
  timeout = "5s"

# Persistent volume for SQLite
[mounts]
  source = "corral_data"
  destination = "/app/data"

Deploy to Fly.io

Fly.io
fly launch Detected Dockerfile. Using existing fly.toml… # Create a volume for SQLite persistence fly volumes create corral_data --size 1 # Set secrets (encrypted at rest) fly secrets set BETTER_AUTH_SECRET=$(openssl rand -base64 32) fly secrets set STRIPE_SECRET_KEY=sk_live_... fly secrets set STRIPE_WEBHOOK_SECRET=whsec_... fly deploy Deployed to https://your-app-name.fly.dev

Secrets management

Fly.io secrets are encrypted and injected as environment variables at runtime. Use fly secrets set KEY=value for sensitive values β€” never put them in fly.toml.

Fly.io secrets
fly secrets set \ BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \ STRIPE_SECRET_KEY="sk_live_..." \ STRIPE_PUBLISHABLE_KEY="pk_live_..." \ STRIPE_WEBHOOK_SECRET="whsec_..." \ GOOGLE_CLIENT_ID="..." \ GOOGLE_CLIENT_SECRET="..." fly secrets list NAME DIGEST CREATED AT BETTER_AUTH_SECRET abc123 2024-01-01 STRIPE_SECRET_KEY def456 2024-01-01

Vercel

For Next.js apps, Corral deploys on Vercel without any special config β€” auth lives in API routes which Vercel handles natively.

SQLite on Vercel
Vercel's serverless functions have no persistent filesystem. SQLite (corral.db) will not work on Vercel serverless. Use Turso (libSQL) or PostgreSQL instead. Set database.adapter: turso or database.adapter: postgres in corral.yaml.

Next.js on Vercel

Corral mounts its auth routes at /api/auth/[...corral] β€” standard Next.js API routes. Vercel deploys them as serverless functions automatically. No extra configuration needed.

Vercel
vercel Detected Next.js app. Deploying… Deployed to https://myapp.vercel.app # Auth routes are serverless functions: https://myapp.vercel.app/api/auth/signin https://myapp.vercel.app/api/auth/session https://myapp.vercel.app/api/billing/checkout

Environment variables in Vercel dashboard

Go to your project β†’ Settings β†’ Environment Variables. Add these for Production (and optionally Preview):

VariableNotes
BETTER_AUTH_SECRET32+ char secret. Must match across all deployments.
DATABASE_URLPostgreSQL or Turso URL (not SQLite)
STRIPE_SECRET_KEYUse sk_test_ for Preview, sk_live_ for Production
STRIPE_PUBLISHABLE_KEYUsed client-side in checkout
STRIPE_WEBHOOK_SECRETFrom Stripe β†’ Webhooks. Set up a separate webhook endpoint for each environment.
GOOGLE_CLIENT_IDOptional β€” only if using Google OAuth
GOOGLE_CLIENT_SECRETOptional
NEXTAUTH_URLSet to your deployment URL for OAuth redirect handling

Using Turso with Vercel

corral.yaml yaml
database:
  adapter: turso
  url: env:TURSO_DATABASE_URL
  auth_token: env:TURSO_AUTH_TOKEN

Docker Compose (Local Dev)

For local development, Docker Compose mounts your source as volumes so changes are picked up without rebuilding. SQLite persists in a named volume.

docker-compose.yml yaml
# Local dev with hot reload. Auth on :3456 (internal), backend on :8000.
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "${PORT:-8000}:8000"
    env_file:
      - .env
    environment:
      - AUTH_PORT=3456
      - AUTH_URL=http://localhost:3456
      - PORT=8000
      - CORRAL_DB_PATH=/app/data/corral.db
    volumes:
      # Hot-reload: mount source code
      - ./server:/app/server
      - ./app:/app/app
      # Persistent SQLite data
      - corral_data:/app/data
      # Avoid overwriting node_modules
      - /app/server/node_modules
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

volumes:
  corral_data:
Docker Compose dev
docker compose up app-1 | πŸ¦™ Corral auth server listening on :3456 app-1 | πŸš€ Backend listening on :8000 # Edit your source code β€” changes auto-reload # SQLite data persists in the corral_data volume

Multi-Language Deployments

Corral's auth server is language-agnostic. Your backend validates sessions by calling http://localhost:3456/api/auth/validate. The pattern is the same regardless of your backend language.

Python + Node auth sidecar

supervisord.conf (Python) ini
[program:auth]
command=node /app/server/auth.js
environment=PORT="3456",CORRAL_DB_PATH="/app/data/corral.db"
autorestart=true
priority=10

[program:backend]
command=/app/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
environment=PORT="8000",AUTH_URL="http://localhost:3456"
autorestart=true
priority=20

Validate sessions from your FastAPI backend:

app/auth.py python
import os, httpx
from fastapi import HTTPException, Cookie

AUTH_URL = os.getenv("AUTH_URL", "http://localhost:3456")

async def get_session(session_token: str = Cookie(None)):
    if not session_token:
        raise HTTPException(status_code=401)
    async with httpx.AsyncClient() as client:
        r = await client.get(
            f"{AUTH_URL}/api/auth/validate",
            headers={"Cookie": f"session={session_token}"}
        )
    if r.status_code != 200:
        raise HTTPException(status_code=401)
    return r.json()  # { user, plan, features }

Go + Node auth sidecar

auth/validate.go go
package auth

import (
    "encoding/json"
    "fmt"
    "net/http"
    "os"
)

var AuthURL = os.Getenv("AUTH_URL") // http://localhost:3456

type Session struct {
    User     map[string]interface{}
    Plan     string
    Features []string
}

func ValidateSession(cookie string) (*Session, error) {
    req, _ := http.NewRequest("GET",
        fmt.Sprintf("%s/api/auth/validate", AuthURL), nil)
    req.Header.Set("Cookie", "session="+cookie)

    resp, err := http.DefaultClient.Do(req)
    if err != nil || resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("unauthorized")
    }
    var session Session
    json.NewDecoder(resp.Body).Decode(&session)
    return &session, nil
}

The "nobody sees Node" principle

The Node auth server is implementation detail, not infrastructure. Your users, your API clients, and your monitoring tools only ever talk to your backend. The Node process could be replaced tomorrow with a different auth implementation β€” your backend code doesn't change.

Architecture
βœ“ Users hit yourdomain.com β†’ your backend (port 8000)
βœ“ Backend calls localhost:3456 β†’ Corral auth (internal)
βœ— Nobody else talks to port 3456 β€” it's not exposed
βœ— No "auth service" in your API gateway β€” no extra routes
βœ— No CORS headers needed for auth β€” same-process call

Environment Variables Reference

Complete reference for all environment variables Corral uses. Variables marked Required must be set in production.

Variable Required Default Description
BETTER_AUTH_SECRET Required β€” 32+ char random string. Signs sessions and tokens. Never share or rotate without invalidating all sessions.
DATABASE_URL Billing CORRAL_DB_PATH PostgreSQL/MySQL connection string. Used when adapter: postgres or adapter: mysql in corral.yaml. Falls back to SQLite if not set.
CORRAL_DB_PATH Optional ./corral.db SQLite file path. Set to a volume mount path for persistence (e.g., /app/data/corral.db).
STRIPE_SECRET_KEY Billing β€” Stripe secret key (sk_live_... or sk_test_...). Used server-side only.
STRIPE_PUBLISHABLE_KEY Billing β€” Stripe publishable key (pk_live_...). Safe to expose client-side. Used in checkout.
STRIPE_WEBHOOK_SECRET Billing β€” Webhook signing secret from Stripe dashboard. Used to verify webhook authenticity.
GOOGLE_CLIENT_ID OAuth β€” Google OAuth app client ID. Required if google is in your auth providers.
GOOGLE_CLIENT_SECRET OAuth β€” Google OAuth app client secret.
GITHUB_CLIENT_ID OAuth β€” GitHub OAuth app client ID. Required if github is in your auth providers.
GITHUB_CLIENT_SECRET OAuth β€” GitHub OAuth app client secret.
AUTH_PORT Sidecar 3456 Port the Corral auth server listens on internally. Change only if 3456 conflicts.
AUTH_URL Sidecar http://localhost:3456 URL your backend uses to reach the auth server. Always localhost for same-container deployments.
PORT Optional 8000 Port your backend listens on. Set automatically by Railway and Fly.io.
SMTP_URL Email β€” SMTP connection string for magic link emails. Example: smtp://user:pass@host:587.
TURSO_DATABASE_URL Turso β€” Turso (libSQL) database URL. Use with Vercel or any serverless environment.
TURSO_AUTH_TOKEN Turso β€” Turso auth token. Required with TURSO_DATABASE_URL.
NODE_ENV Optional development Set to production in production. Affects Stripe mode (live vs test), cookie security, and logging.
Generate BETTER_AUTH_SECRET
openssl rand -base64 32 β€” run this and use the output. It must be at least 32 characters. The same value must be used across all instances of your app (important for horizontal scaling).