config: env-driven CORS, ports, log level, timezone

The CORS allowed-origins list, host port mappings, log level, container
timezone, and worker concurrency are now all driven by environment
variables with sane defaults. Same-origin access through the nginx
proxy keeps working with no config; direct cross-origin backend
access can be locked down via ALLOWED_ORIGINS.

- backend/config: ALLOWED_ORIGINS env (comma-separated, "*" for any)
  exposed via settings.cors_origins. LOG_LEVEL too.
- backend/main: build the CORS middleware from settings.cors_origins,
  auto-disable allow_credentials when origins is wildcard (CORS spec
  forbids credentials + "*").
- docker-compose: parameterize FRONTEND_PORT, BACKEND_PORT, REDIS_PORT,
  CELERYD_CONCURRENCY, LOG_LEVEL, and TZ via ${VAR:-default} so each
  has a working fallback if the .env entry is missing.
- .env.example: new template documenting every knob with examples.
- .env: pruned to only the values that diverge from .env.example;
  removed dead VITE_API_URL.
- README: configuration knobs table + "accessing from another machine"
  section explaining the same-origin proxy story.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 20:31:59 +02:00
parent ac5b18b60c
commit fc63b1f69d
6 changed files with 189 additions and 49 deletions

40
.env
View File

@@ -1,30 +1,20 @@
# Environment variables for Mulita # Mulita / PhotoVault local environment.
# # See .env.example for the full list of knobs and their docs.
# Set PHOTO_DIRS to the HOST path of your photo library. The compose file
# mounts this at /photos inside the container, and on first boot Mulita # REQUIRED — host path to your photo library.
# auto-creates a source root pointing at /photos so your library is
# scanned with zero further configuration.
#
# Examples:
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
# Network share: PHOTO_DIRS=/mnt/nas/photos
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
PHOTO_DIRS=/Users/dtoro/Pictures/MulitaTest PHOTO_DIRS=/Users/dtoro/Pictures/MulitaTest
# Redis configuration # Ports — change if 3000 / 8001 collide with other services on the host.
REDIS_URL=redis://localhost:6379 FRONTEND_PORT=3000
BACKEND_PORT=8001
REDIS_PORT=6379
# Database URL # CORS — wildcard for local dev. Lock down for real deployments.
DATABASE_URL=sqlite+aiosqlite:///data/db/mulita.db ALLOWED_ORIGINS=*
# Celery configuration # Logging + timezone.
CELERY_BROKER_URL=redis://localhost:6379 LOG_LEVEL=INFO
CELERY_RESULT_BACKEND=redis://localhost:6379 TZ=UTC
# Celery worker pool.
CELERYD_CONCURRENCY=4 CELERYD_CONCURRENCY=4
# API settings
API_HOST=0.0.0.0
API_PORT=8000
# Frontend settings
VITE_API_URL=http://localhost:8000

83
.env.example Normal file
View File

@@ -0,0 +1,83 @@
# ─────────────────────────────────────────────────────────────────────────────
# Mulita / PhotoVault — example environment file
#
# Copy this file to `.env` and adjust the values for your setup. Every key
# below has a sensible default in docker-compose.yml, so you only need to
# uncomment the ones you actually want to change.
# ─────────────────────────────────────────────────────────────────────────────
# ── REQUIRED ─────────────────────────────────────────────────────────────────
# Host path to your photo library. The compose file mounts this at /photos
# inside the backend + worker containers. The backend creates a default
# source root pointing at /photos on first boot, so once this is set the
# library is scanned with zero further configuration.
#
# Examples:
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
# Network share: PHOTO_DIRS=/mnt/nas/photos
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
PHOTO_DIRS=./photos
# ── PORTS ────────────────────────────────────────────────────────────────────
# Host port the SPA is served on. Browse to http://<host>:<FRONTEND_PORT>/.
FRONTEND_PORT=3000
# Host port for the backend API. Almost never needed directly — the frontend
# nginx proxies /api/ to the backend over the internal compose network. Kept
# exposed for debugging / curl.
BACKEND_PORT=8001
# Redis host port. Internal services reach Redis on its container name; this
# is just for local debugging.
REDIS_PORT=6379
# ── CORS ─────────────────────────────────────────────────────────────────────
# Comma-separated list of allowed origins for direct browser access to the
# backend. Same-origin requests through the nginx / vite proxy never trip
# CORS, so this only matters when something hits the backend port directly
# from a different origin (e.g. another machine, dev tools, a reverse proxy
# under a different hostname).
#
# Default "*" is permissive, fine for a single-user homelab. Lock it down in
# real deployments:
# ALLOWED_ORIGINS=https://photos.example.com
# ALLOWED_ORIGINS=https://photos.example.com,http://192.168.1.10:3000
ALLOWED_ORIGINS=*
# ── LOGGING / TIMEZONE ───────────────────────────────────────────────────────
# Python log level for the backend and Celery worker. Bump to DEBUG when
# chasing scan / thumbnail issues.
LOG_LEVEL=INFO
# Container timezone. Affects the timestamps in logs and the "added at"
# field on newly imported photos. Defaults to UTC.
# TZ=Europe/Berlin
# TZ=America/New_York
TZ=UTC
# ── WORKER CONCURRENCY ───────────────────────────────────────────────────────
# How many parallel Celery worker processes to spin up. Each one can run
# one scan / thumbnail / metadata job at a time. Bump on a beefy host with a
# big library; lower on a Pi.
CELERYD_CONCURRENCY=4
# ── INTERNAL (rarely overridden) ─────────────────────────────────────────────
# These point at the in-compose Redis and the bind-mounted SQLite db. Override
# only if you're running Mulita without docker-compose or against an external
# Redis.
# REDIS_URL=redis://redis:6379
# CELERY_BROKER_URL=redis://redis:6379
# CELERY_RESULT_BACKEND=redis://redis:6379
# DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db

View File

@@ -43,19 +43,16 @@ git clone <repository-url>
cd muleimage cd muleimage
``` ```
2. Set **one** environment variable in `.env` — the **host** directory 2. Copy the example env file and set **one** variable — the **host**
that contains your photo library. Whatever you point at will become directory that contains your photo library. Whatever you point at
your library inside Mulita. will become your library inside Mulita.
```bash ```bash
# macOS / Linux cp .env.example .env
PHOTO_DIRS=/Users/you/Pictures # then edit .env and set PHOTO_DIRS:
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
# or any folder # Network share: PHOTO_DIRS=/mnt/nas/photos
PHOTO_DIRS=/mnt/nas/photos # Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
# Windows (WSL)
PHOTO_DIRS=/mnt/c/Users/you/Pictures
``` ```
3. Start the stack: 3. Start the stack:
@@ -70,6 +67,38 @@ docker compose up -d
You don't need to touch `mulita.yml` or the API to get started. You don't need to touch `mulita.yml` or the API to get started.
### Configuration knobs
Everything is environment-driven. `PHOTO_DIRS` is the only required
value; the rest have sensible defaults documented in `.env.example`:
| Variable | Default | Notes |
|----------------------|---------|----------------------------------------------------|
| `PHOTO_DIRS` | — | **Required.** Host path mounted at `/photos`. |
| `FRONTEND_PORT` | `3000` | SPA host port. Bump if `3000` is taken. |
| `BACKEND_PORT` | `8001` | Direct backend port (debug only — frontend uses internal nginx proxy). |
| `REDIS_PORT` | `6379` | Redis host port (internal services don't need it). |
| `ALLOWED_ORIGINS` | `*` | Comma-separated CORS origins for direct backend access. Lock down for prod, e.g. `https://photos.example.com`. |
| `LOG_LEVEL` | `INFO` | Backend + worker log level. `DEBUG` for chasing scan issues. |
| `TZ` | `UTC` | Container timezone. Affects log timestamps and "added at". |
| `CELERYD_CONCURRENCY`| `4` | Parallel worker processes (scans, thumbs, metadata). Lower on a Pi, higher on a beefy host. |
### Accessing from another machine
The frontend talks to the backend through its bundled nginx, which
proxies `/api/` to the backend on the internal compose network. That
means requests are always **same-origin** as the page, so accessing
Mulita from another host works without any CORS dance:
```
http://<your-server-ip>:3000
```
If you want to put it behind a reverse proxy at e.g.
`https://photos.your.tld`, set `ALLOWED_ORIGINS` to that host so the
backend's direct port (`BACKEND_PORT`) also accepts cross-origin
requests if anything bypasses the proxy.
### How libraries are managed ### How libraries are managed
Mulita is **config-driven**: the host directory you mount via Mulita is **config-driven**: the host directory you mount via

View File

@@ -70,7 +70,29 @@ class Settings(BaseSettings):
# API settings # API settings
api_host: str = Field(default="0.0.0.0", env="API_HOST") api_host: str = Field(default="0.0.0.0", env="API_HOST")
api_port: int = Field(default=8000, env="API_PORT") api_port: int = Field(default=8000, env="API_PORT")
# CORS — comma-separated list of allowed origins, or "*" for any.
# Same-origin requests (the normal case behind nginx / vite proxy)
# never trip CORS, so this is only for direct browser access from
# other origins (LAN IP, reverse proxy, dev tools).
allowed_origins: str = Field(default="*", env="ALLOWED_ORIGINS")
# Logging — accepts standard python levels (DEBUG, INFO, WARNING,
# ERROR, CRITICAL). Bumped from INFO when chasing a problem.
log_level: str = Field(default="INFO", env="LOG_LEVEL")
@property
def cors_origins(self) -> list[str]:
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:
- "*" → wildcard (single-element list ["*"])
- "http://a.com,http://b.com" → split + strip
Empty entries are dropped.
"""
raw = (self.allowed_origins or "").strip()
if not raw or raw == "*":
return ["*"]
return [o.strip() for o in raw.split(",") if o.strip()]
# App configuration from YAML # App configuration from YAML
_config: Optional[MulitaConfig] = None _config: Optional[MulitaConfig] = None

View File

@@ -64,14 +64,18 @@ app = FastAPI(
# Configure CORS. The frontend normally talks to the backend through the # Configure CORS. The frontend normally talks to the backend through the
# nginx (prod) or vite (dev) proxy, so requests are same-origin and never # nginx (prod) or vite (dev) proxy, so requests are same-origin and never
# trip CORS. The wildcard here is a fallback for the rare case where a # trip CORS. ALLOWED_ORIGINS in .env controls the fallback for direct
# user / script hits the backend directly from a browser at some other # browser access from other origins (LAN IP, reverse proxy under a
# origin (LAN IP, reverse proxy under a different host, etc.). This is a # different host). Defaults to "*" since this is a single-user homelab
# single-user homelab tool, so a permissive CORS policy is fine. # tool; lock it down by setting e.g. ALLOWED_ORIGINS=https://photos.your.tld
# in production deployments.
_origins = settings.cors_origins
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=_origins,
allow_credentials=False, # Wildcard origins can't be combined with credentials per the CORS
# spec, so credentials get auto-disabled in that case.
allow_credentials=_origins != ["*"],
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )

View File

@@ -2,12 +2,14 @@ version: '3.8'
services: services:
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: mulita-frontend container_name: mulita-frontend
ports: ports:
- "3000:80" # Host port is configurable via FRONTEND_PORT in .env so multiple
# instances / other services on the same host don't collide.
- "${FRONTEND_PORT:-3000}:80"
depends_on: depends_on:
- backend - backend
networks: networks:
@@ -20,7 +22,10 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: mulita-backend container_name: mulita-backend
ports: ports:
- "8001:8000" # Direct backend access on the host is rarely needed (the frontend
# talks to it through the nginx /api proxy on the same network),
# but it's exposed for debugging / curl. Override with BACKEND_PORT.
- "${BACKEND_PORT:-8001}:8000"
volumes: volumes:
- ./mulita.yml:/app/config/mulita.yml:ro - ./mulita.yml:/app/config/mulita.yml:ro
# The single host → container mount for your photo library. Set # The single host → container mount for your photo library. Set
@@ -38,6 +43,9 @@ services:
- CELERY_BROKER_URL=redis://redis:6379 - CELERY_BROKER_URL=redis://redis:6379
- CELERY_RESULT_BACKEND=redis://redis:6379 - CELERY_RESULT_BACKEND=redis://redis:6379
- PHOTO_DIRS=${PHOTO_DIRS:-/photos} - PHOTO_DIRS=${PHOTO_DIRS:-/photos}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- TZ=${TZ:-UTC}
depends_on: depends_on:
- redis - redis
networks: networks:
@@ -49,7 +57,7 @@ services:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: mulita-worker container_name: mulita-worker
command: celery -A app.tasks.celery worker --loglevel=info --concurrency=4 command: celery -A app.tasks.celery worker --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERYD_CONCURRENCY:-4}
volumes: volumes:
- ./mulita.yml:/app/config/mulita.yml:ro - ./mulita.yml:/app/config/mulita.yml:ro
- ${PHOTO_DIRS:-./photos}:/photos:rw - ${PHOTO_DIRS:-./photos}:/photos:rw
@@ -62,7 +70,9 @@ services:
- CELERY_BROKER_URL=redis://redis:6379 - CELERY_BROKER_URL=redis://redis:6379
- CELERY_RESULT_BACKEND=redis://redis:6379 - CELERY_RESULT_BACKEND=redis://redis:6379
- PHOTO_DIRS=${PHOTO_DIRS:-/photos} - PHOTO_DIRS=${PHOTO_DIRS:-/photos}
- CELERYD_CONCURRENCY=4 - CELERYD_CONCURRENCY=${CELERYD_CONCURRENCY:-4}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- TZ=${TZ:-UTC}
depends_on: depends_on:
- redis - redis
- backend - backend
@@ -73,8 +83,10 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: mulita-redis container_name: mulita-redis
# Host port exposed only for local debugging; the backend / worker
# reach Redis via the internal mulita-network on its container name.
ports: ports:
- "6379:6379" - "${REDIS_PORT:-6379}:6379"
volumes: volumes:
- redis_data:/data - redis_data:/data
networks: networks: