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.
corral deploy docker|fly|railway|render, then deploy with your platform CLI.
corral deploy docker
corral deploy fly
corral deploy railway
corral deploy renderThe "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.
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.
# ============================================================
# 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.
; 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
Environment variables for Docker
Create a .env file (never commit this):
# 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
{
"$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
Environment variables in Railway dashboard
Go to your service β Variables tab. Add these:
| Variable | Value |
|---|---|
BETTER_AUTH_SECRET | Generate with openssl rand -base64 32 |
STRIPE_SECRET_KEY | Your Stripe live/test secret key |
STRIPE_WEBHOOK_SECRET | From Stripe webhook dashboard |
DATABASE_URL | Railway PostgreSQL plugin provides this automatically |
GOOGLE_CLIENT_ID | From Google Cloud Console |
GOOGLE_CLIENT_SECRET | From Google Cloud Console |
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.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
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.
Vercel
For Next.js apps, Corral deploys on Vercel without any special config β auth lives in API routes which Vercel handles natively.
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.
Environment variables in Vercel dashboard
Go to your project β Settings β Environment Variables. Add these for Production (and optionally Preview):
| Variable | Notes |
|---|---|
BETTER_AUTH_SECRET | 32+ char secret. Must match across all deployments. |
DATABASE_URL | PostgreSQL or Turso URL (not SQLite) |
STRIPE_SECRET_KEY | Use sk_test_ for Preview, sk_live_ for Production |
STRIPE_PUBLISHABLE_KEY | Used client-side in checkout |
STRIPE_WEBHOOK_SECRET | From Stripe β Webhooks. Set up a separate webhook endpoint for each environment. |
GOOGLE_CLIENT_ID | Optional β only if using Google OAuth |
GOOGLE_CLIENT_SECRET | Optional |
NEXTAUTH_URL | Set to your deployment URL for OAuth redirect handling |
Using Turso with Vercel
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.
# 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:
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
[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:
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
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.
yourdomain.com β your backend (port 8000)localhost:3456 β Corral auth (internal)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 |
β | 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. |
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).