diff --git a/.env.example b/.env.example
index 16a61c2444..a7be8ba1be 100644
--- a/.env.example
+++ b/.env.example
@@ -6,7 +6,6 @@
# │ Reference: docs/ENVIRONMENT.md for full details and usage scenarios. │
# └─────────────────────────────────────────────────────────────────────────────┘
-
# ═══════════════════════════════════════════════════════════════════════════════
# 1. REQUIRED SECRETS — Must be set before first run!
# ═══════════════════════════════════════════════════════════════════════════════
@@ -62,7 +61,6 @@ DISABLE_SQLITE_AUTO_BACKUP=false
# Default: redis://localhost:6379 (or redis://redis:6379 in Docker)
REDIS_URL=redis://localhost:6379
-
# ═══════════════════════════════════════════════════════════════════════════════
# 3. NETWORK & PORTS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -159,6 +157,11 @@ OMNIROUTE_USE_TURBOPACK=1
# Values: production | development | Default: production
NODE_ENV=production
+# Container runtime — controls startup script behavior (permissions, advice).
+# Values: docker | podman | Default: docker
+# Set to "podman" when running under rootless Podman so the entrypoint
+# gives the correct fix instructions (podman unshare chown vs sudo chown).
+CONTAINER_HOST=docker
# ═══════════════════════════════════════════════════════════════════════════════
# 4. SECURITY & AUTHENTICATION
@@ -238,7 +241,6 @@ ALLOW_API_KEY_REVEAL=false
# When unset, OmniRoute uses the per-feature defaults. Set to "false"/"0" to disable.
# OUTBOUND_SSRF_GUARD_ENABLED=true
-
# ═══════════════════════════════════════════════════════════════════════════════
# 5. INPUT SANITIZATION & PII PROTECTION (FASE-01)
# ═══════════════════════════════════════════════════════════════════════════════
@@ -271,7 +273,6 @@ ALLOW_API_KEY_REVEAL=false
# PII_RESPONSE_SANITIZATION=false
# PII_RESPONSE_SANITIZATION_MODE=redact # redact = mask PII | warn = log only | block = drop response
-
# ═══════════════════════════════════════════════════════════════════════════════
# 6. TOOL & ROUTING POLICIES
# ═══════════════════════════════════════════════════════════════════════════════
@@ -291,7 +292,6 @@ ALLOW_API_KEY_REVEAL=false
# Default: 5000 | Minimum: 1000
# OMNIROUTE_PAYLOAD_RULES_RELOAD_MS=5000
-
# ═══════════════════════════════════════════════════════════════════════════════
# 7. URLS & CLOUD SYNC
# ═══════════════════════════════════════════════════════════════════════════════
@@ -365,7 +365,6 @@ NEXT_PUBLIC_CLOUD_URL=
#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit
-
# ═══════════════════════════════════════════════════════════════════════════════
# 8. OUTBOUND PROXY (Upstream Provider Calls)
# ═══════════════════════════════════════════════════════════════════════════════
@@ -393,7 +392,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Used by: open-sse/services/claudeTurnstileSolver.ts
# OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS=false
-
# ═══════════════════════════════════════════════════════════════════════════════
# 9. CLI TOOL INTEGRATION
# ═══════════════════════════════════════════════════════════════════════════════
@@ -423,7 +421,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# CLI_QODER_BIN=qoder
# CLI_QWEN_BIN=qwen
-
# ═══════════════════════════════════════════════════════════════════════════════
# 10. INTERNAL AGENT & MCP INTEGRATIONS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -554,7 +551,6 @@ PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
# Default: ~/.gemini/antigravity-cli/antigravity-oauth-token
#AGY_TOKEN_FILE=
-
# ═══════════════════════════════════════════════════════════════════════════════
# 11. OAUTH PROVIDER CREDENTIALS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -701,7 +697,6 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# CLI_USER_ID= # legacy alias for OMNIROUTE_USER_ID
# SERVER_URL= # legacy alias for OMNIROUTE_SERVER
-
# ═══════════════════════════════════════════════════════════════════════════════
# 12. PROVIDER USER-AGENT OVERRIDES
# ═══════════════════════════════════════════════════════════════════════════════
@@ -737,7 +732,6 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
# CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts.
# CODEX_CLIENT_VERSION=0.132.0
-
# ═══════════════════════════════════════════════════════════════════════════════
# 13. CLI FINGERPRINT COMPATIBILITY (Anti-Detection)
# ═══════════════════════════════════════════════════════════════════════════════
@@ -766,7 +760,6 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
#KIMI_CLI_VERSION=1.36.0
#KIMI_CODING_DEVICE_ID=
-
# ═══════════════════════════════════════════════════════════════════════════════
# 14. API KEY PROVIDERS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -789,7 +782,6 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
# OpenAI/Mistral/Together/Fireworks/NVIDIA configured via Dashboard → Providers
# also work for embeddings.
-
# ═══════════════════════════════════════════════════════════════════════════════
# 15. TIMEOUT SETTINGS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -868,7 +860,6 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
# Default: 30000 (30 seconds)
# SHUTDOWN_TIMEOUT_MS=30000
-
# ═══════════════════════════════════════════════════════════════════════════════
# 16. LOGGING
# ═══════════════════════════════════════════════════════════════════════════════
@@ -942,7 +933,6 @@ APP_LOG_TO_FILE=true
# Default: 100000
# PROXY_LOGS_TABLE_MAX_ROWS=100000
-
# ═══════════════════════════════════════════════════════════════════════════════
# 17. MEMORY OPTIMIZATION (Low-RAM / Docker)
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1022,7 +1012,6 @@ APP_LOG_TO_FILE=true
# Used by: open-sse/utils/usageTracking.ts
# USAGE_TOKEN_BUFFER=100
-
# ═══════════════════════════════════════════════════════════════════════════════
# 18. PRICING SYNC
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1038,7 +1027,6 @@ APP_LOG_TO_FILE=true
# Comma-separated data sources. Default: litellm
# PRICING_SYNC_SOURCES=litellm
-
# ═══════════════════════════════════════════════════════════════════════════════
# 19. MODEL SYNC (Dev)
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1047,7 +1035,6 @@ APP_LOG_TO_FILE=true
# Default: 86400 (24 hours)
# MODELS_DEV_SYNC_INTERVAL=86400
-
# ═══════════════════════════════════════════════════════════════════════════════
# 20. PROVIDER-SPECIFIC SETTINGS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1128,7 +1115,6 @@ APP_LOG_TO_FILE=true
# Used by: open-sse/config/providerRegistry.ts — allows Docker service names.
# LOCAL_HOSTNAMES=omlx,mlx-audio
-
# ═══════════════════════════════════════════════════════════════════════════════
# 21. PROXY HEALTH
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1154,7 +1140,6 @@ APP_LOG_TO_FILE=true
# Used by: src/lib/tokenHealthCheck.ts. Default: 3000.
# HEALTHCHECK_STAGGER_MS=3000
-
# ═══════════════════════════════════════════════════════════════════════════════
# 22. DEBUGGING
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1214,7 +1199,6 @@ APP_LOG_TO_FILE=true
# Enable E2E test mode — relaxes auth and enables test harness hooks.
# NEXT_PUBLIC_OMNIROUTE_E2E_MODE=true
-
# ═══════════════════════════════════════════════════════════════════════════════
# 23. GITHUB INTEGRATION (Issue Reporting)
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1232,7 +1216,6 @@ APP_LOG_TO_FILE=true
# GITHUB_ISSUES_TOKEN when unset.
# GITHUB_TOKEN=
-
# ═══════════════════════════════════════════════════════════════════════════════
# 24. PROVIDER QUOTAS, TUNNELS & SANDBOXED SKILLS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1337,7 +1320,6 @@ APP_LOG_TO_FILE=true
# SKILLS_SANDBOX_NETWORK_ENABLED=0
# SKILLS_ALLOWED_SANDBOX_IMAGES=
-
# ═══════════════════════════════════════════════════════════════════════════════
# 25. TEST & E2E
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1448,8 +1430,8 @@ INSPECTOR_MASK_SECRETS=true
INSPECTOR_LLM_HOSTS_EXTRA=
INSPECTOR_INTERNAL_INGEST_TOKEN=
# Quota Sharing (Group B — planos 16+22)
-QUOTA_STORE_DRIVER=sqlite # sqlite | redis
-# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
-# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo)
-# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa
-# QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos
+QUOTA_STORE_DRIVER=sqlite # sqlite | redis
+# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
+# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo)
+# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa
+# QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos
diff --git a/README.md b/README.md
index ab938f7545..5bfa7ec184 100644
--- a/README.md
+++ b/README.md
@@ -540,6 +540,22 @@ devbox run npm run dev
📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) — Compose profiles, Caddy HTTPS, Cloudflare tunnels.
+**🦭 Podman**
+
+```bash
+# 1. Build the image
+podman build --target runner-base -t omniroute:base .
+
+# 2. Fix data directory permissions for rootless Podman
+mkdir -p data && podman unshare chown 1000:1000 ./data
+
+# 3. Set runtime in .env, then run (see contrib/podman/ for Quadlet)
+echo "CONTAINER_HOST=podman" >> .env
+podman compose --profile base up -d
+```
+
+📖 [Podman Guide](contrib/podman/README.md) — Quadlet setup, podman-compose, Quadlet.
+
@@ -779,6 +795,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
| Document | Description |
| ---------------------------------------------------- | -------------------------------------------------------------- |
| [Docker Guide](docs/guides/DOCKER_GUIDE.md) | Docker run, Compose profiles, Caddy HTTPS, tunnels, image tags |
+| [Podman Guide](contrib/podman/README.md) | Quadlet systemd integration, podman-compose, SELinux |
| [VM Deployment](docs/ops/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup |
| [Fly.io Deployment](docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md) | Deploy to Fly.io with persistent storage |
| [Termux Guide](docs/guides/TERMUX_GUIDE.md) | Run OmniRoute on Android via Termux |
diff --git a/contrib/podman/README.md b/contrib/podman/README.md
new file mode 100644
index 0000000000..7ebc6fc9b0
--- /dev/null
+++ b/contrib/podman/README.md
@@ -0,0 +1,128 @@
+# Podman Deployment
+
+Run OmniRoute with Podman via **Quadlet** (systemd integration) or **podman compose**.
+
+---
+
+## Option A: Quadlet (recommended)
+
+### 1. Build the image
+
+```bash
+cd /path/to/omniroute
+podman build --target runner-base -t omniroute:base .
+# For web-cookie providers (gemini-web, claude-web, claude-turnstile):
+podman build --target runner-web -t omniroute:web .
+# For CLI tool support:
+podman build --target runner-cli -t omniroute:cli .
+```
+
+### 2. Copy Quadlet files to the systemd directory
+
+```bash
+mkdir -p ~/.config/containers/systemd/omniroute
+cp contrib/podman/*.container ~/.config/containers/systemd/omniroute/
+cp contrib/podman/*.network ~/.config/containers/systemd/omniroute/
+cp contrib/podman/*.volume ~/.config/containers/systemd/omniroute/
+```
+
+### 3. Mount the project .env for secrets
+
+Edit `~/.config/containers/systemd/omniroute/omniroute.container` and
+uncomment/replace the `EnvironmentFile` line with the absolute path to
+your project `.env`:
+
+```
+EnvironmentFile=/home/USER/code/docker/OmniRoute/.env
+```
+
+Make sure `CONTAINER_HOST=podman` is set in that `.env`.
+
+Alternatively, edit the env vars directly in the `.container` file.
+
+### 4. Reload systemd and start
+
+```bash
+systemctl --user daemon-reload
+systemctl --user start omniroute-redis
+systemctl --user start omniroute
+```
+
+### 5. Verify
+
+```bash
+systemctl --user status omniroute
+curl http://localhost:20128/v1/models
+```
+
+To follow logs:
+
+```bash
+journalctl --user -u omniroute -f
+```
+
+### 6. Enable on boot
+
+```bash
+systemctl --user enable omniroute-redis
+systemctl --user enable omniroute
+```
+
+---
+
+## Option B: podman compose
+
+The project's `docker-compose.yml` now works with both Docker and Podman.
+Just set `CONTAINER_HOST=podman` in `.env` before starting.
+
+### 1. Prepare the data directory
+
+Rootless Podman maps container UIDs into a subordinate range. The
+`node` user (UID 1000) inside the container maps to a different UID
+on the host, so it cannot write to `./data` owned by your host user.
+
+Fix the ownership **before** starting:
+
+```bash
+mkdir -p data
+podman unshare chown 1000:1000 ./data
+```
+
+### 2. Set the runtime in `.env`
+
+Make sure `.env` contains:
+
+```env
+CONTAINER_HOST=podman
+```
+
+### 3. Start
+
+```bash
+podman compose --profile base up -d
+```
+
+### Profiles
+
+Same profiles as `docker compose`:
+
+| Profile | Command |
+| ------------------------------ | ---------------------------------------------------- |
+| `base` (no CLIs) | `podman compose --profile base up -d` |
+| `web` (+Chromium/Playwright) | `podman compose --profile web up -d` |
+| `cli` (+CLI tools) | `podman compose --profile cli up -d` |
+| `host` (host-mounted binaries) | `podman compose --profile host up -d` |
+| `cliproxyapi` (sidecar) | `podman compose --profile cliproxyapi up -d` |
+
+---
+
+## How it works
+
+The `docker-compose.yml` uses fully-qualified image names
+(`docker.io/library/redis:7-alpine`) and flat variable expansions so it
+works with both Docker and Podman without a separate compose file.
+
+The entrypoint script (`check-permissions.sh`) reads `CONTAINER_HOST`
+from `.env` to give the correct fix instructions:
+- **docker**: `sudo chown -R ... ./data`
+- **podman**: `podman unshare chown 1000:1000 ./data`
diff --git a/contrib/podman/omniroute-redis.container b/contrib/podman/omniroute-redis.container
new file mode 100644
index 0000000000..326ac92559
--- /dev/null
+++ b/contrib/podman/omniroute-redis.container
@@ -0,0 +1,26 @@
+[Unit]
+Description=OmniRoute Redis (rate-limiter backend)
+After=network-online.target
+Wants=network-online.target
+
+[Container]
+Image=docker.io/redis:7-alpine
+ContainerName=omniroute-redis
+
+Network=omniroute.network
+NetworkAlias=redis
+
+Volume=omniroute-redis.volume:/data
+
+Exec=redis-server --save 60 1 --loglevel warning
+
+HealthCmd=/bin/sh -c "redis-cli ping | grep -q PONG"
+HealthInterval=10s
+HealthRetries=3
+HealthTimeout=5s
+
+[Service]
+Restart=always
+
+[Install]
+WantedBy=default.target
diff --git a/contrib/podman/omniroute-redis.volume b/contrib/podman/omniroute-redis.volume
new file mode 100644
index 0000000000..e14c448891
--- /dev/null
+++ b/contrib/podman/omniroute-redis.volume
@@ -0,0 +1,3 @@
+# Persistent Redis data
+[Volume]
+Label=omniroute
diff --git a/contrib/podman/omniroute.container b/contrib/podman/omniroute.container
new file mode 100644
index 0000000000..846e681a27
--- /dev/null
+++ b/contrib/podman/omniroute.container
@@ -0,0 +1,50 @@
+[Unit]
+Description=OmniRoute — Unified AI Proxy
+After=network-online.target
+Wants=network-online.target
+Requires=omniroute-redis.service
+After=omniroute-redis.service
+
+[Container]
+# Use this image if your building locally
+Image=localhost/omniroute:base
+# Use this image if you are pulling from Docker Hub
+# Image=diegosouzapw/omniroute:base
+
+ContainerName=omniroute
+
+Network=omniroute.network
+PublishPort=20128:20128
+PublishPort=20129:20129
+
+Volume=omniroute.volume:/app/data:Z
+
+# Use a bind mount if you need direct host access to the data:
+# Volume=/home/USER/code/docker/OmniRoute/data:/app/data:Z
+
+Environment=DATA_DIR=/app/data
+Environment=PORT=20128
+Environment=DASHBOARD_PORT=20128
+Environment=API_PORT=20129
+Environment=API_HOST=0.0.0.0
+Environment=REDIS_URL=redis://redis:6379
+Environment=JWT_SECRET=change-me-to-a-random-base64-string
+Environment=API_KEY_SECRET=change-me-to-a-random-base64-string
+Environment=INITIAL_PASSWORD=change-me-to-a-random-hex-string
+Environment=NODE_ENV=production
+Environment=REQUIRE_API_KEY=true
+
+# Load additional secrets (API keys, OAuth creds) from the project .env:
+# EnvironmentFile=%h/code/docker/OmniRoute/.env
+
+HealthCmd=node /app/healthcheck.mjs
+HealthInterval=30s
+HealthRetries=3
+HealthTimeout=5s
+HealthStartPeriod=15s
+
+[Service]
+Restart=always
+
+[Install]
+WantedBy=default.target
diff --git a/contrib/podman/omniroute.network b/contrib/podman/omniroute.network
new file mode 100644
index 0000000000..4f618e9943
--- /dev/null
+++ b/contrib/podman/omniroute.network
@@ -0,0 +1,2 @@
+[Network]
+Label=omniroute
diff --git a/contrib/podman/omniroute.volume b/contrib/podman/omniroute.volume
new file mode 100644
index 0000000000..d0d3ae8cbc
--- /dev/null
+++ b/contrib/podman/omniroute.volume
@@ -0,0 +1,3 @@
+# OmniRoute persistent data (SQLite DB, logs, backups)
+[Volume]
+Label=omniroute
diff --git a/docker-compose.yml b/docker-compose.yml
index 8a249e335f..e30f4c0d2a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -27,7 +27,7 @@ x-common: &common
environment:
- DATA_DIR=/app/data # Must match the volume mount below
- PORT=${PORT:-20128}
- - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}}
+ - DASHBOARD_PORT=${DASHBOARD_PORT:-20128}
- API_PORT=${API_PORT:-20129}
- API_HOST=${API_HOST:-0.0.0.0}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
@@ -43,7 +43,7 @@ x-common: &common
services:
# ── Redis (Rate Limiter Backend) ──────────────────────────────────
redis:
- image: redis:7-alpine
+ image: docker.io/library/redis:7-alpine
container_name: omniroute-redis
restart: unless-stopped
ports:
@@ -66,7 +66,7 @@ services:
target: runner-base
image: omniroute:base
ports:
- - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}"
+ - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${API_PORT:-20129}:${API_PORT:-20129}"
profiles:
- base
@@ -83,7 +83,7 @@ services:
target: runner-web
image: omniroute:web
ports:
- - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}"
+ - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${API_PORT:-20129}:${API_PORT:-20129}"
profiles:
- web
@@ -97,7 +97,7 @@ services:
target: runner-cli
image: omniroute:cli
ports:
- - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}"
+ - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${API_PORT:-20129}:${API_PORT:-20129}"
volumes:
- ./data:/app/data
@@ -116,12 +116,12 @@ services:
target: runner-base
image: omniroute:base
ports:
- - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}"
+ - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${API_PORT:-20129}:${API_PORT:-20129}"
environment:
- DATA_DIR=/app/data
- PORT=${PORT:-20128}
- - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}}
+ - DASHBOARD_PORT=${DASHBOARD_PORT:-20128}
- API_PORT=${API_PORT:-20129}
- API_HOST=${API_HOST:-0.0.0.0}
- CLI_MODE=host
diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md
index c3be99fc53..fd103553fe 100644
--- a/docs/reference/ENVIRONMENT.md
+++ b/docs/reference/ENVIRONMENT.md
@@ -877,6 +877,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | Maximum SQLite backup files retained on disk. |
| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. |
| `OMNIROUTE_TLS_PROXY_URL` | _(unset)_ | `open-sse/services/chatgptTlsClient.ts` | Override the TLS sidecar URL for tests. Production should leave unset. |
+| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | Container runtime hint for the entrypoint permission check. Set to `podman` under rootless Podman so the fix instructions use `podman unshare chown` instead of `sudo chown`. |
| `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | Quota-share consumption store backend: `sqlite` (default) or `redis`. |
| `QUOTA_STORE_REDIS_URL` | _(unset)_ | `src/lib/quota/storeFactory.ts` | Redis connection string used when `QUOTA_STORE_DRIVER=redis` (e.g. `redis://localhost:6379`). |
| `QUOTA_SATURATION_THRESHOLD` | `0.5` | `src/lib/quota/enforce.ts` | Pool saturation ratio (0..1); at/above it the pool enters strict mode (no borrowing). |
diff --git a/next.config.mjs b/next.config.mjs
index ce4252d2bf..1dc08a6dcf 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -104,6 +104,10 @@ const nextConfig = {
serverActions: {
bodySizeLimit: process.env.OMNIROUTE_SERVER_ACTIONS_BODY_LIMIT || "50mb",
},
+ // Next.js proxy (middleware) has a default 10MB body clone limit. File
+ // uploads (OpenAI-compatible /v1/files) routinely exceed this. Match the
+ // 512 MB server-side cap; tune via env if needed.
+ proxyClientMaxBodySize: process.env.NEXT_PROXY_BODY_LIMIT || "512mb",
},
outputFileTracingRoot: projectRoot,
outputFileTracingIncludes: {
diff --git a/scripts/check-permissions.sh b/scripts/check-permissions.sh
index a58d046351..0c25116673 100755
--- a/scripts/check-permissions.sh
+++ b/scripts/check-permissions.sh
@@ -8,11 +8,18 @@ if [ -n "$OMNIROUTE_MEMORY_MB" ]; then
export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=${OMNIROUTE_MEMORY_MB}"
fi
-if [ -d "/app/data" ] && [ ! -w "/app/data" ]; then
- echo "WARNING: /app/data is not writable by the current user (UID $(id -u))."
- echo "Run this on the Docker host to fix:"
- echo " sudo chown -R $(id -u):$(id -g) /app/data"
- echo " chmod -R u+rwX ./data"
+DATA_PATH="${DATA_DIR:-/app/data}"
+if [ -d "$DATA_PATH" ] && [ ! -w "$DATA_PATH" ]; then
+ echo "WARNING: $DATA_PATH is not writable by the current user (UID $(id -u))."
+ if [ "${CONTAINER_HOST:-}" = "podman" ]; then
+ echo "Rootless Podman maps container UIDs into a subordinate range."
+ echo "Run this on the host to fix (using the host-side bind-mount path):"
+ echo " podman unshare chown -R $(id -u):$(id -g) "
+ else
+ echo "Run this on the Docker host to fix (using the host-side bind-mount path):"
+ echo " sudo chown -R $(id -u):$(id -g) "
+ echo " chmod -R u+rwX "
+ fi
fi
exec "$@"
diff --git a/src/shared/middleware/bodySizeGuard.ts b/src/shared/middleware/bodySizeGuard.ts
index d9ef26fce3..eefd2b23d2 100644
--- a/src/shared/middleware/bodySizeGuard.ts
+++ b/src/shared/middleware/bodySizeGuard.ts
@@ -26,6 +26,9 @@ export const MAX_BODY_BYTES_IMPORT = 100 * 1024 * 1024;
/** Larger limit for audio transcription uploads: 100 MB */
export const MAX_BODY_BYTES_AUDIO = 100 * 1024 * 1024;
+/** Larger limit for file uploads: 500 MB */
+export const MAX_BODY_BYTES_FILE = 500 * 1024 * 1024;
+
/** Configured limit — reads from env or falls back to 10 MB */
export const MAX_BODY_BYTES = parseRequestBodyLimitBytes(process.env.MAX_BODY_SIZE_BYTES);
@@ -34,6 +37,7 @@ type BodySizeRule = { prefix: string; limit: number };
const ROUTE_LIMITS: BodySizeRule[] = [
{ prefix: "/api/db-backups/import", limit: MAX_BODY_BYTES_IMPORT },
{ prefix: "/api/v1/audio/transcriptions", limit: MAX_BODY_BYTES_AUDIO },
+ { prefix: "/api/v1/files", limit: MAX_BODY_BYTES_FILE },
];
export function getDefaultRequestBodyLimitMb(): number {
@@ -62,7 +66,7 @@ export function checkBodySize(request: Request, limit: number = MAX_BODY_BYTES):
const contentLength = request.headers.get("content-length");
if (contentLength) {
- const bytes = parseInt(contentLength, 10);
+ const bytes = Number.parseInt(contentLength, 10);
if (!Number.isNaN(bytes) && bytes > limit) {
return new Response(
JSON.stringify({
diff --git a/tests/unit/body-size-guard.test.ts b/tests/unit/body-size-guard.test.ts
index 2f5b967ea2..fd877c7223 100644
--- a/tests/unit/body-size-guard.test.ts
+++ b/tests/unit/body-size-guard.test.ts
@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import {
MAX_BODY_BYTES_AUDIO,
+ MAX_BODY_BYTES_FILE,
getBodySizeLimit,
checkBodySize,
} from "../../src/shared/middleware/bodySizeGuard.ts";
@@ -40,3 +41,39 @@ test("checkBodySize reports the configured request limit in 413 responses", asyn
assert.equal(body.error.code, "PAYLOAD_TOO_LARGE");
assert.match(body.error.message, /100 MB/);
});
+
+test("/api/v1/files route has 512 MB dedicated limit floor", () => {
+ const limit = getBodySizeLimit("/api/v1/files", { maxBodySizeMb: 1 });
+ assert.equal(limit, MAX_BODY_BYTES_FILE);
+});
+
+test("/api/v1/files route guard allows 500 MB file upload", () => {
+ const thirtyMb = 500 * 1024 * 1024;
+ const request = new Request("http://localhost/api/v1/files", {
+ method: "POST",
+ headers: { "content-length": String(thirtyMb) },
+ });
+ assert.equal(checkBodySize(request, getBodySizeLimit("/api/v1/files", { maxBodySizeMb: 10 })), null);
+});
+
+test("/api/v1/files route guard rejects >512 MB file upload", async () => {
+ const tooBig = 600 * 1024 * 1024;
+ const request = new Request("http://localhost/api/v1/files", {
+ method: "POST",
+ headers: { "content-length": String(tooBig) },
+ });
+ const response = checkBodySize(request, getBodySizeLimit("/api/v1/files", { maxBodySizeMb: 10 }));
+ assert.ok(response);
+ assert.equal(response.status, 413);
+ const body = await response.json();
+ assert.equal(body.error.code, "PAYLOAD_TOO_LARGE");
+});
+
+test("/api/v1/files route guard allows 15 MB (10 MB+ real-world scenario)", () => {
+ const fifteenMb = 15 * 1024 * 1024;
+ const request = new Request("http://localhost/api/v1/files", {
+ method: "POST",
+ headers: { "content-length": String(fifteenMb) },
+ });
+ assert.equal(checkBodySize(request, getBodySizeLimit("/api/v1/files")), null);
+});