Skip to content

Backend Deployment

This deployment scaffold publishes the FastAPI backend through Cloudflare Tunnel for api.nursescheduling.org. Cloudflare terminates public HTTPS, while cloudflared forwards requests to NGINX on a Docker network.

Environment

The deployment reads the ignored docker/.env file. The tracked docker/.env.example documents every variable and is the source of truth. Variables that must be set before the first start:

  • CLOUDFLARE_TUNNEL_TOKEN: the token of the Cloudflare Tunnel from the dashboard.
  • API_AUTH_TOKEN or API_AUTH_TOKENS: the deployment images require at least one backend key.
  • AI_PROVIDER_BASE_URL, AI_PROVIDER_API_KEY, and E2B_API_KEY: the AI service starts with both Compose variants and fails startup without its provider and sandbox settings. Set AI_AUTH_TOKEN or AI_AUTH_TOKENS before starting unless you explicitly set AI_AUTH_REQUIRED=false.

Notable optional variables: SENTRY_BACKEND_DSN (an unset DSN keeps the shared development project), SENTRY_ENVIRONMENT, DISABLE_SENTRY, DIAGNOSTIC_TARGET_URL and the other DIAGNOSTIC_* values, USAGE_REPORT_TRANSPORT with the Mailgun values, and the OPTIMIZE_* allowlist. Local optimization development needs no environment variables.

Cloudflare Tunnel

  • Create a Cloudflare Tunnel.
  • Add a public hostname for api.nursescheduling.org.
  • Point the hostname service to http://nginx:8080. NGINX sends /ai/* to the AI service and all other paths to the optimization API.
  • Copy .env.example to .env.
  • Set CLOUDFLARE_TUNNEL_TOKEN in .env to the token from the dashboard.
  • Set API_AUTH_TOKEN or API_AUTH_TOKENS in .env. The deployment image requires at least one key.
  • Enable Always Use HTTPS.
  • (Optional) Add a WAF/rate limit rule for POST /optimize.
  • Keep ports 80 and 443 closed on the VM unless another service needs them.

We used Cloudflare Tunnel for ease of setup, but you can easily switch to NGINX and Certbot if you have a dedicated public IP and are comfortable exposing it to the internet.

API Authentication

Compose deployments are internet-facing, so they authenticate by default. Dockerfile.api and Dockerfile.api.staging set API_AUTH_REQUIRED=true in the image, which makes an empty credential set a startup failure:

API_AUTH_REQUIRED is set, so API_AUTH_TOKEN or API_AUTH_TOKENS must not be empty

Generate keys and put either the legacy single key or identified keys in the environment file:

openssl rand -base64 32
# Backward-compatible single key
API_AUTH_TOKEN=generated-key

# Or multiple static keys
API_AUTH_TOKENS='{"institution-a":"generated-key","person-b":"another-generated-key"}'

IDs may contain letters, numbers, underscores, and hyphens. legacy is reserved for API_AUTH_TOKEN. IDs are used only in server logs and are not returned to clients. Clients still send only the key. Both settings may be used during migration, but IDs and keys must be unique. Remove a pair and restart the service to revoke it.

Serving a Compose deployment with no authentication is possible but has to be chosen, by setting API_AUTH_REQUIRED=false in .env. That overrides the value baked into the image.

Running the server outside these images leaves API_AUTH_REQUIRED unset, so local development stays unauthenticated with no extra configuration.

Use at least 16 characters per key. When API_AUTH_REQUIRED=true, the backend rejects shorter keys. When it is false, a shorter key is accepted with a warning for local testing. Requests present only the key as a bearer credential:

curl -H "Authorization: Bearer ${AUTH_KEY}" https://api.nursescheduling.org/optimize/options

GET /info and GET /ready stay public so clients and deployment probes can discover the deployment without credentials. /info reports "auth": {"required": true, "scheme": "bearer"}, which the frontend uses to prompt for a token before calling a protected route. Every other application route, including / and all of /optimize, answers 401 with a WWW-Authenticate: Bearer header when the key is missing or wrong. Keys are resolved through a process-local keyed fingerprint map, then compared in constant time. A request does not scan every configured key. 401 responses are not reported to Sentry because unauthenticated probes are expected.

When authentication is configured, the generated /openapi.json, /docs, and /redoc routes are disabled and return 404.

Running the backend outside Compose leaves both key settings unset, so local development stays unauthenticated and needs no frontend changes.

The diagnostic service reads DIAGNOSTIC_AUTH_TOKEN, which defaults to API_AUTH_TOKEN. When using only API_AUTH_TOKENS, set DIAGNOSTIC_AUTH_TOKEN to one of its keys.

The AI service in both backend Compose files applies the same secure default with AI_AUTH_REQUIRED=true. Set AI_AUTH_TOKEN or AI_AUTH_TOKENS before starting Compose. The latter uses a JSON object mapping IDs to keys. To deliberately serve without AI authentication, set AI_AUTH_REQUIRED=false in docker/.env and leave both settings empty. Native runs leave required mode disabled, although setting either one still enables bearer authentication.

Both Compose variants enable AI session recovery through a fixed private PostgreSQL service connection. The database uses the persistent postgres-ai-data volume and is not published on a host port. See session recovery storage for retention and failure behavior.

Cloudflared and NGINX share tunnel. NGINX and the API share api, which AI also uses to call the optimizer. NGINX and AI share ai for public AI routes. The diagnostic service joins tunnel. The API joins redis, while AI joins postgres. The optional inspection UIs join only the network for the datastore they inspect. Docker allocates network addresses, so production and staging can run on one host without configured subnets.

For an existing deployment, inspect its project networks with docker network inspect and check for the com.docker.compose.config-hash label. Compose may reuse an older network without that label and retain its fixed subnet. If a network lacks the label, recreate the affected project from the docker/ directory. For production:

docker compose --env-file .env -f compose.backend.yml down
docker compose --env-file .env -f compose.backend.yml up -d --build

For staging:

docker compose --env-file .env.staging -f compose.backend.yml down
APP_VERSION="$(git -C .. describe --tags --always --dirty)" \
  docker compose --env-file .env.staging -f compose.backend.yml up -d --build

Use compose.backend.memory.yml instead when that is the deployed variant. Keep the deployment's project name and environment file, and omit -v from down to preserve named volumes.

Remove obsolete *_NETWORK_SUBNET, *_NETWORK_DYNAMIC_RANGE, *_NETWORK_GATEWAY, NGINX_API_IP, CLOUDFLARED_TUNNEL_IP, and FORWARDED_ALLOW_IPS settings from existing env files. Keep the Cloudflare Tunnel hostname service at http://nginx:8080. NGINX replaces public X-Forwarded-For with Cloudflare's CF-Connecting-IP. Uvicorn accepts that header from any local container, so this setup assumes sibling containers are trusted. Do not publish the API or AI ports directly to the internet.

For local inspection, start the loopback-only pgAdmin UI and open http://127.0.0.1:5050:

docker compose --env-file .env -f compose.backend.yml --profile inspection run --rm --service-ports pgadmin

See inspect chat history with pgAdmin for login, remote SSH forwarding, connection, and query instructions.

NGINX removes the /ai prefix before forwarding requests to this service and disables response buffering for its streaming endpoints. Keep the Cloudflare Tunnel hostname pointed at http://nginx:8080, not directly at either application container.

AI Chat History Backup and Restore

Run these commands from docker/:

cd docker

The examples target production with --env-file .env -f compose.backend.yml. For staging, use --env-file .env.staging -f compose.backend.yml on every command, for example:

docker compose --env-file .env.staging -f compose.backend.yml exec -T postgres \
  pg_dump --username=ai_history --dbname=ai_history --format=custom --no-owner --no-acl \
  > "../backups/ai-history-staging-$(date +%Y%m%d-%H%M%S).dump"

Create ../backups before either backup command. When the deployed variant uses compose.backend.memory.yml, substitute that file on every command and keep its production or staging environment file. Preserve any custom project name used by the deployment. The environment file selects the Compose project, including its PostgreSQL volume, so use the same selection for backup, dump verification, and every restore step.

Backup

mkdir -p ../backups

docker compose --env-file .env -f compose.backend.yml exec -T postgres \
  pg_dump \
  --username=ai_history \
  --dbname=ai_history \
  --format=custom \
  --no-owner \
  --no-acl \
  > "../backups/ai-history-$(date +%Y%m%d-%H%M%S).dump"

The dump is stored on the host at <repo-root>/backups/ai-history-YYYYMMDD-HHMMSS.dump. Check existing backups with:

ls -lh ../backups/

Verify that the selected dump is readable before restoring it:

docker compose --env-file .env -f compose.backend.yml exec -T postgres \
  pg_restore --list < ../backups/ai-history-YYYYMMDD-HHMMSS.dump

Restore

Stop the AI service first:

docker compose --env-file .env -f compose.backend.yml stop ai

Recreate the database:

docker compose --env-file .env -f compose.backend.yml exec -T postgres \
  dropdb --username=ai_history --if-exists ai_history

docker compose --env-file .env -f compose.backend.yml exec -T postgres \
  createdb --username=ai_history ai_history

Restore the selected backup:

docker compose --env-file .env -f compose.backend.yml exec -T postgres \
  pg_restore \
    --username=ai_history \
    --dbname=ai_history \
    --no-owner \
    --no-acl \
    --exit-on-error < ../backups/ai-history-YYYYMMDD-HHMMSS.dump

Restart the AI service only after a successful restore. If restore fails, leave the AI service stopped:

docker compose --env-file .env -f compose.backend.yml start ai

Verify the restored database:

docker compose --env-file .env -f compose.backend.yml exec postgres \
  psql -U ai_history -d ai_history \
  -c '\dt'

Backup Retention

For example, delete backups older than 30 days:

find ../backups -name 'ai-history-*.dump' -mtime +30 -delete

These backups may contain the full stored AI conversation history, including reasoning, tool calls and results, attachment filenames, and schedule or attachment text exposed through tools. Treat backup files as sensitive data and protect or encrypt off-host copies appropriately.

Sentry

The Docker deployment configures only the Python backend. Keep all backend servers in one Sentry project so issues, releases, alerts, and performance data stay aggregated. Use SENTRY_ENVIRONMENT to distinguish production and staging. Sentry records the backend host name as server_name, so separate backend machines remain filterable without creating a project for every server.

Set the projects' public DSNs in the deployment environment file:

SENTRY_BACKEND_DSN=https://[email protected]/backend-project-id
SENTRY_ENVIRONMENT=production

Compose passes SENTRY_BACKEND_DSN to every Python service as SENTRY_DSN. The API, usage reporter, and diagnostic share the project and environment while remaining filterable through their app tags. SENTRY_AUTH_TOKEN is not needed by the running backend because the SDK sends events through the DSN. Do not add a frontend DSN or Sentry auth token to this backend environment file. Configure them in the frontend build environment as described in the developer guide.

An unset DSN retains the repository's existing shared Sentry project. Running outside Docker uses the development environment. Set DISABLE_SENTRY=1 in the deployment environment file to disable reporting for every Python service.

Start

Run these commands from the docker/ directory.

The production API image uses Dockerfile.api and clones the latest dev branch from GitHub in a temporary build stage. The build records a clean git describe --tags --always --dirty value in .app-version, then copies only core/ and that version file into the runtime image. Git metadata and the Git binary are excluded from the final image.

cd docker
docker compose -f compose.backend.yml up -d --build

BuildKit caches the dependency install layer across source changes and reuses downloaded packages when the requirements change. Unpinned packages are resolved again only when the install layer is invalidated. The pip cache stays out of the runtime image.

The API derives one deployment ID from its container and server-launch identity and shares it across all Uvicorn workers. The one-shot public diagnostic is opt-in and does not start with the normal deployment command. The normal Compose startup also starts one experimental AI worker. When using E2B, this worker builds and publishes its sandbox template before it becomes ready. Configure the AI block in .env before running:

docker compose -f compose.backend.yml up -d --build

The same behavior applies to compose.backend.memory.yml.

For staging, create a separate ignored environment file and use a staging-only Cloudflare Tunnel token:

cp .env.staging.example .env.staging
# Set the tunnel token, backend auth keys, and diagnostic target in .env.staging.
APP_VERSION="$(git -C .. describe --tags --always --dirty)" \
  docker compose --env-file .env.staging -f compose.backend.yml up -d --build

The staging environment selects Dockerfile.api.staging, which copies the current repository's core/ directory into the image instead of cloning GitHub. The host derives the app version before the build, and the Dockerfile writes it to .app-version in the image. Linked Git worktrees are supported and .git stays out of the build context and final image. Staging shares the BuildKit pip cache with production and sets COMPOSE_PROJECT_NAME=nurse-scheduling-backend-staging. This overrides the default nurse-scheduling-backend project name and gives staging its own containers, network, and redis-data volume. Production and staging can then run side by side on the same host. Staging also publishes its E2B sandbox to nurse-scheduling-ai-sandbox-staging, leaving the production template alias independent. Existing ignored .env.staging files need the same E2B_TEMPLATE value when upgrading.

Always pass --env-file .env.staging for every staging command, including ps, logs, and down. Without it, Docker Compose loads .env and targets production by default.

docker compose --env-file .env.staging -f compose.backend.yml ps
docker compose --env-file .env.staging -f compose.backend.yml logs -f
docker compose --env-file .env.staging -f compose.backend.yml down

The default compose file starts Redis alongside the backend. The backend runs with:

  • JOB_BACKEND=redis
  • JOB_REDIS_URL=redis://redis:6379/0
  • JOB_REDIS_KEY_PREFIX=nurse_scheduling:jobs:v0
  • JOB_WORKER_LEASE_SECONDS=90 by default
  • JOB_MAX_EVENTS_PER_JOB=1000 by default
  • USAGE_METRICS_ENABLED=true
  • USAGE_METRICS_RETENTION_DAYS=30 by default

To inspect this Redis database through a temporary, loopback-only UI, run:

docker compose -f compose.backend.yml --profile inspection run --rm --service-ports redisinsight

Open http://127.0.0.1:5540. See inspect Redis with RedisInsight for remote access, key prefixes, and data-safety guidance.

The backend publishes its accepted run options at GET /optimize/options. The frontend uses this response for solver choices, timeout limits, running-job controls, and the prettify default. Configure the response with:

  • OPTIMIZE_SOLVERS, a comma-separated allowlist of selectors from the solver reference
  • OPTIMIZE_DEFAULT_SOLVER
  • OPTIMIZE_MIN_TIMEOUT_SECONDS
  • OPTIMIZE_DEFAULT_TIMEOUT_SECONDS
  • OPTIMIZE_MAX_TIMEOUT_SECONDS
  • OPTIMIZE_DEFAULT_PRETTIFY

The safe default exposes only ortools/cp-sat. In an existing GPU-capable backend environment, copy the GPU settings template and set its tunnel token:

cp .env.gpu.example .env

This exposes both main solvers and selects pulp/cuopt by default. The template does not add GPU access or cuOpt to the included CPU-only Compose deployment.

The API refuses to start if any configured solver is unavailable or a default falls outside its advertised choices or range.

A complete compute benchmark creates claimed-performance.env. Merge its three CLAIMED_PERFORMANCE_* values into the deployment environment to publish the self-claimed score and provenance at GET /info. All three values must be set together. The frontend displays the claimed score when the backend is selected.

The API container runs multiple Uvicorn workers. Each worker claims jobs from Redis and runs at most one optimization job locally. Workers renew shared presence leases while idle and running. A job is failed and its capacity is released if its owning worker lease expires.

To run one backend worker with process-local memory and no Redis service, use the pre-Redis deployment configuration:

docker compose -f compose.backend.memory.yml up -d --build

Use the same compose file name for later commands when running the memory configuration.

Check the API through Cloudflare:

curl https://api.nursescheduling.org/ready
curl https://api.nursescheduling.org/info
curl -H "Authorization: Bearer ${API_AUTH_TOKEN}" \
  https://api.nursescheduling.org/optimize/options

Run the public healthcheck test:

./test_public_healthcheck.sh

Check the backend directly from the VM:

docker compose -f compose.backend.yml exec api curl -fsS http://127.0.0.1:8000/ready

Check Redis from the API container:

docker compose -f compose.backend.yml exec api redis-cli -u redis://redis:6379/0 ping

/ready is the minimal deployment probe. /info performs the same readiness check and adds the app version, deployment ID, process instance ID, process start time, job backend, opaque job store ID, and whether authentication is required. Both responses disable caching and stay public. The frontend uses /info so one request provides readiness, version, and authentication information.

Weekly Usage Reports

The Redis deployment collects minimal per-job telemetry. Collection is enabled by Compose and disabled by default for direct development launches. It records job and pseudonymous client IDs, solver, lifecycle timestamps and state, queue and runtime durations, outcome, failure code, solver status, termination reason, configured timeout, download count, people count, shift type count, and schedule date range. It does not retain uploaded YAML or record people and shift identifiers, descriptions, filenames, IP addresses, or email addresses.

Buckets run from Sunday at 00:00 through the next Sunday at 00:00 in the host machine timezone. Each event belongs to the week when it occurs, so a job submitted on Saturday and completed on Sunday can contribute to two different weekly buckets. The reporter retrieves only the selected seven-day bucket. The API and reporter mount the host timezone files so their boundaries remain consistent, including daylight-saving transitions. Reports contain one CSV row per associated job. Fields that are not available for an ongoing job remain empty. Reporting does not remove telemetry. Rows and weekly membership indexes expire after the configured retention interval following the end of their event week. The interval defaults to 30 days. Values below nine days are rejected because they cannot cover a complete week before its reporting deadline. Set USAGE_METRICS_ENABLED=false in the Docker environment file to disable collection for a self-hosted deployment.

The default deployment runs one weekly service that stores delivery status in Redis and writes reports to its container log by default. On startup it catches up on completed, unsent weeks still covered by telemetry retention. It then sleeps until the next reporting deadline.

Get the API key from the Mailgun dashboard under Send > Sending > Domain settings > Sending keys. If Mailgun reports that the sender domain does not exist, verify its records under Domain settings > DNS records. Then configure these values in .env:

USAGE_REPORT_TRANSPORT=mailgun
USAGE_REPORT_SUBJECT="Nurse Scheduling backend usage: {week_id}"
MAILGUN_API_KEY=key-example
MAILGUN_DOMAIN=mg.example.com
MAILGUN_FROM="Nurse Scheduling Reports <[email protected]>"
[email protected]

The reporter warns when Mailgun settings are present while the transport is still stdout. In mailgun mode, it warns and stops when required Mailgun settings are missing.

Then start the normal deployment with the reporter:

docker compose -f compose.backend.yml up -d --build

The reporter delivers the completed week on Sunday at or shortly after 00:00 in the host machine timezone. Set USAGE_REPORT_LOCAL_HOUR to another local hour from 0 through 23. A Redis-backed guard leaves at least ten minutes between any two delivery attempts, including catch-up reports, service restarts, and two bounded retries during a weekly run. The scheduler also waits at least ten minutes between runs as a safeguard against invalid deadline logic. Delivery checkpoints prevent normal duplicate sends. A process failure or ambiguous transport error after Mailgun accepts a message but before Redis records the checkpoint can still cause a retry and duplicate delivery. MAILGUN_API_URL accepts Mailgun's HTTPS US or EU v3 endpoint for regional delivery.

USAGE_REPORT_SUBJECT controls both the email subject and the first line of the report body. It supports the optional {week_id} placeholder. Invalid or multiline templates stop the reporter during startup.

Trigger completed unsent reports immediately, without waiting for Sunday:

docker compose -f compose.backend.yml run --rm \
  usage-reporter python -m nurse_scheduling.server.usage_report --once

The command exits with a nonzero status if any delivery fails.

To send the newest retained week immediately, including the current partial week or one already checkpointed as sent, add --force:

docker compose -f compose.backend.yml run --rm \
  usage-reporter python -m nurse_scheduling.server.usage_report --once --force

The command regenerates the entire selected Sunday-to-Sunday bucket from retained telemetry instead of sending only changes since the previous report. For the current week, it includes data recorded so far without marking the week complete, so the normal full report is still delivered after Sunday. Forced sends bypass the shared delivery interval but retain the per-week lock. No retained telemetry or a held lock is reported as an error with a nonzero exit status. A failed forced resend preserves any previous successful delivery checkpoint. --force is rejected without --once.

For a one-time local rendering, use the default stdout transport. This marks the report as delivered, so use an isolated Redis namespace if it must still be emailed later:

docker compose -f compose.backend.yml run --rm usage-reporter \
  python -m nurse_scheduling.server.usage_report --once

Public Diagnostic

The diagnostic service uses the diagnostic Compose profile so normal backend startup does not submit diagnostic jobs. Run it explicitly against the configured public URL:

docker compose -f compose.backend.yml --profile diagnostic \
  run --rm --no-deps diagnostic

After starting the staging deployment, run the diagnostic against its configured public URL with the staging environment:

docker compose --env-file .env.staging -f compose.backend.yml \
  --profile diagnostic run --rm --no-deps diagnostic

Run it directly from a repository checkout, outside Docker Compose, after installing the core dependencies. From the repository root:

source core/.venv/bin/activate
cd core
python -m nurse_scheduling.server.diagnostic \
  --target-url https://api.nursescheduling.org \
  --report-dir ../docker/diagnostic-reports

The same DIAGNOSTIC_* environment variables configure direct runs. The --target-url, --scenario, and --report-dir arguments override their corresponding environment values.

Set DIAGNOSTIC_TARGET_URL in the environment file to select production, staging, or another public backend. Relevant defaults are:

  • DIAGNOSTIC_INFO_SAMPLES=100
  • DIAGNOSTIC_PARALLEL_REQUESTS=10
  • DIAGNOSTIC_EXPECTED_CONCURRENCY=3 for the Redis deployment
  • DIAGNOSTIC_MAX_JOBS=128
  • DIAGNOSTIC_QUEUE_STABLE_SECONDS=10
  • DIAGNOSTIC_WORKFLOW_TIMEOUT_SECONDS=600
  • DIAGNOSTIC_JOB_TIMEOUT_SECONDS=3600

The workflow timeout bounds startup sampling, job submission, and queue transition checks. Cleanup and individual solver jobs have separate bounds.

The diagnostic samples /info and job snapshots with bounded concurrency, then merges those identities with the API process recorded when each job is accepted and the actual runner recorded when it starts. It submits the real 87-person scenario until five jobs remain queued and measures the peak number of its own running jobs. It cancels the first running job and finishes the others using their feasible results. Queued jobs are then finished in batches matching the observed peak concurrency. Every submitted job has a one-hour solver timeout and bounded cleanup.

The diagnostic needs DIAGNOSTIC_EXPECTED_CONCURRENCY plus five active jobs from one address. A deployment with keys exempts it through DIAGNOSTIC_AUTH_TOKEN. An open deployment counts its jobs against JOB_MAX_PENDING_PER_CLIENT, so raise that limit while the diagnostic runs. Otherwise the run reports client_job_capacity_reached and is inconclusive.

The first successful response is printed immediately. The concluding summary includes the observed job backend. The example timing values below only show the output format and are not measured deployment performance:

CONNECTED target=https://api.nursescheduling.org http_status=200 status=ready
PASS target=https://api.nursescheduling.org job_type=large-ward-with-87-people-2025-11 job_backend=redis versions=1 deployments=1 instances=3 runners=3 stores=1 maxRunning=3 queue=PASS cleanup=PASS duration=148.0s
TIMING readiness=0.2s info_sampling=1.5s info_analysis=0.0s queue_saturation=12.0s queue_transition=132.0s identity_analysis=0.1s cleanup=2.2s

Timestamped JSON reports are written to the project-scoped diagnostic-reports named volume. The summary and findings appear first, followed by phase durations, distinct sampled responses, and job identities with occurrence counts, job transitions, request errors, and cleanup details. Exit codes are 0 for pass, 1 for a definite failure, and 2 for an inconclusive run. Copy reports to the host when needed:

mkdir -p diagnostic-reports
docker compose -f compose.backend.yml run --rm --no-deps \
  --user "$(id -u):$(id -g)" \
  --volume "$(pwd)/diagnostic-reports:/export" \
  --entrypoint sh diagnostic -c 'cp -R /reports/. /export/'

The image prepares /reports for its normal non-root user, so the diagnostic run needs no user override. The extraction command uses the host user only to write the bind-mounted destination. The volume survives normal docker compose down, but down -v removes it.

Different app versions, deployment IDs, job backends, or automatically derived job store IDs behind one public URL are configuration failures. Redis stores persist a UUID inside their database and key namespace. Memory stores use the process instance ID. A cloned Redis snapshot can duplicate its UUID, so equal IDs remain evidence rather than proof of sharing. Cross-worker job visibility is the definitive shared-store check.

Results are observational. Cloudflare routing can repeatedly select one instance, so discovered instances are a lower bound. Unrelated users can consume capacity or alter timing and make a run inconclusive without proving a code or deployment defect.

Frontend

The frontend selects an available backend from its built-in candidate list at page load.

When JOB_BACKEND=redis, optimization jobs, SSE events, YAML inputs, and XLSX outputs are stored in Redis so status, event, and download requests can be served by any backend worker.

The bundled Redis service retains an AOF and a less-frequent RDB fallback in the redis-data volume. The AOF uses appendfsync everysec, which limits the usual abrupt-failure exposure to approximately the latest second while adding disk I/O for stored YAML, event streams, XLSX artifacts, and telemetry. A single RDB rule creates a snapshot after six hours when at least one write has occurred, so an RDB-only recovery can be up to six hours behind. Redis also receives a one-minute Compose shutdown grace period so its final blocking RDB save can finish during planned restarts. Normal restart and down operations retain the named volume. down -v removes all persisted Redis data. Back up the volume when recovery from host or volume loss is required.

This configuration assumes a new Redis volume. Do not switch an existing RDB-only volume to this configuration without migrating or discarding its data.