{
  "manifest_version": "1.0.0",
  "template": {
    "id": "1b1ab3a5-0995-4864-b55c-50509f9c8f10",
    "slug": "surfsense",
    "name": "SurfSense",
    "description": "Open-source AI research agent (NotebookLM alternative)",
    "url": "https://railway.com/deploy/surfsense",
    "upstream": {
      "image": "caddy:2-alpine"
    }
  },
  "services": [
    {
      "name": "frontend",
      "source": {
        "image": "ghcr.io/modsetter/surfsense-web:latest"
      },
      "needs_volume": false,
      "http": false
    },
    {
      "name": "zero-cache",
      "source": {
        "image": "rocicorp/zero:1.6.0"
      },
      "needs_volume": true,
      "volume_mount_path": "/data",
      "http": false
    },
    {
      "name": "db",
      "source": {
        "image": "pgvector/pgvector:pg17"
      },
      "needs_volume": true,
      "volume_mount_path": "/var/lib/postgresql/data",
      "http": false
    },
    {
      "name": "backend",
      "source": {
        "image": "ghcr.io/modsetter/surfsense-backend:latest"
      },
      "needs_volume": true,
      "volume_mount_path": "/app/.local_object_store",
      "http": false
    },
    {
      "name": "proxy",
      "source": {
        "image": "caddy:2-alpine"
      },
      "needs_volume": false,
      "http": true
    },
    {
      "name": "redis",
      "source": {
        "image": "redis:8.2.1"
      },
      "needs_volume": true,
      "volume_mount_path": "/data",
      "http": false
    }
  ],
  "required_inputs": [
    {
      "key": "PORT",
      "service": "frontend",
      "description": "Port the Next.js standalone server listens on.",
      "secret": false,
      "strategy": "default",
      "default": "3000"
    },
    {
      "key": "AUTH_TYPE",
      "service": "frontend",
      "description": "Must match the backend's AUTH_TYPE.",
      "secret": false,
      "strategy": "default",
      "default": "LOCAL"
    },
    {
      "key": "ETL_SERVICE",
      "service": "frontend",
      "description": "Must match the backend's ETL_SERVICE (drives upload UI hints).",
      "secret": false,
      "strategy": "default",
      "default": "DOCLING"
    },
    {
      "key": "DEPLOYMENT_MODE",
      "service": "frontend",
      "description": "Must match the backend's DEPLOYMENT_MODE.",
      "secret": false,
      "strategy": "default",
      "default": "self-hosted"
    },
    {
      "key": "ZERO_QUERY_API_KEY",
      "service": "frontend",
      "description": "Shared secret validating requests from zero-cache; references zero-cache's generated value so they always match.",
      "secret": true,
      "strategy": "railway_provided",
      "railway_source": "reference_variable"
    },
    {
      "key": "SURFSENSE_BACKEND_INTERNAL_URL",
      "service": "frontend",
      "description": "Backend URL for Next.js server-side calls over private networking (browser calls go same-origin through the proxy).",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "PORT",
      "service": "zero-cache",
      "description": "Informational; Zero listens on 4848 regardless.",
      "secret": false,
      "strategy": "default",
      "default": "4848"
    },
    {
      "key": "ZERO_CVR_DB",
      "service": "zero-cache",
      "description": "Client-view-record database (same Postgres instance).",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "ZERO_CHANGE_DB",
      "service": "zero-cache",
      "description": "Change-log database (same Postgres instance).",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "ZERO_QUERY_URL",
      "service": "zero-cache",
      "description": "Frontend endpoint that resolves Zero queries (delegates auth to the app).",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "ZERO_AUTO_RESET",
      "service": "zero-cache",
      "description": "If replication halts, wipe and re-sync the local replica without touching Postgres.",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "ZERO_MUTATE_URL",
      "service": "zero-cache",
      "description": "Required by Zero when auth tokens are used; SurfSense's mutate endpoint is a no-op.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "ZERO_UPSTREAM_DB",
      "service": "zero-cache",
      "description": "Upstream Postgres that zero-cache replicates from via logical replication.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "ZERO_REPLICA_FILE",
      "service": "zero-cache",
      "description": "SQLite replica location, on the volume so restarts don't re-sync from scratch.",
      "secret": false,
      "strategy": "default",
      "default": "/data/zero.db"
    },
    {
      "key": "ZERO_CVR_MAX_CONNS",
      "service": "zero-cache",
      "description": "Max CVR Postgres connections; must be >= ZERO_NUM_SYNC_WORKERS.",
      "secret": false,
      "strategy": "default",
      "default": "30"
    },
    {
      "key": "ZERO_QUERY_API_KEY",
      "service": "zero-cache",
      "description": "Generated shared secret for the zero-cache -> /api/zero/query hop. The frontend references this value.",
      "secret": true,
      "strategy": "generate",
      "generate": "random_base64_32"
    },
    {
      "key": "ZERO_ADMIN_PASSWORD",
      "service": "zero-cache",
      "description": "Generated password protecting Zero's admin endpoints.",
      "secret": true,
      "strategy": "generate",
      "generate": "strong_password"
    },
    {
      "key": "ZERO_APP_PUBLICATIONS",
      "service": "zero-cache",
      "description": "Postgres publication to replicate; created by SurfSense's Alembic migrations on first backend boot.",
      "secret": false,
      "strategy": "default",
      "default": "zero_publication"
    },
    {
      "key": "ZERO_NUM_SYNC_WORKERS",
      "service": "zero-cache",
      "description": "Sync worker count (the image defaults to CPU cores, which can exceed connection pool limits).",
      "secret": false,
      "strategy": "default",
      "default": "4"
    },
    {
      "key": "ZERO_UPSTREAM_MAX_CONNS",
      "service": "zero-cache",
      "description": "Max upstream Postgres connections; must be >= ZERO_NUM_SYNC_WORKERS.",
      "secret": false,
      "strategy": "default",
      "default": "20"
    },
    {
      "key": "ZERO_QUERY_FORWARD_COOKIES",
      "service": "zero-cache",
      "description": "Forward browser session cookies on the zero-cache -> query-route hop.",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS",
      "service": "zero-cache",
      "description": "How often open sockets re-check auth revocation.",
      "secret": false,
      "strategy": "default",
      "default": "60"
    },
    {
      "key": "ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS",
      "service": "zero-cache",
      "description": "How often open sockets re-apply RBAC membership changes.",
      "secret": false,
      "strategy": "default",
      "default": "60"
    },
    {
      "key": "PGDATA",
      "service": "db",
      "description": "Subdirectory of the volume mount so initdb tolerates the volume's lost+found.",
      "secret": false,
      "strategy": "default",
      "default": "/var/lib/postgresql/data/pgdata"
    },
    {
      "key": "POSTGRES_DB",
      "service": "db",
      "description": "Database created on first boot.",
      "secret": false,
      "strategy": "default",
      "default": "surfsense"
    },
    {
      "key": "POSTGRES_USER",
      "service": "db",
      "description": "Database superuser created on first boot.",
      "secret": false,
      "strategy": "default",
      "default": "surfsense"
    },
    {
      "key": "POSTGRES_PASSWORD",
      "service": "db",
      "description": "Generated password for POSTGRES_USER. Referenced by the backend and zero-cache connection URLs.",
      "secret": true,
      "strategy": "generate",
      "generate": "strong_password"
    },
    {
      "key": "PORT",
      "service": "backend",
      "description": "Port Railway probes for the /ready healthcheck; uvicorn listens on 8000. Do not set UVICORN_HOST=:: (Railway probes over IPv4).",
      "secret": false,
      "strategy": "default",
      "default": "8000"
    },
    {
      "key": "AUTH_TYPE",
      "service": "backend",
      "description": "Authentication mode: LOCAL (email/password) or GOOGLE (OAuth, requires GOOGLE_OAUTH_CLIENT_ID/_SECRET). Must match the frontend's AUTH_TYPE.",
      "secret": false,
      "strategy": "default",
      "default": "LOCAL"
    },
    {
      "key": "SECRET_KEY",
      "service": "backend",
      "description": "Generated JWT / session signing secret (FastAPI Users).",
      "secret": true,
      "strategy": "generate",
      "generate": "random_base64_32"
    },
    {
      "key": "BACKEND_URL",
      "service": "backend",
      "description": "Public URL the backend advertises for itself (OAuth callbacks etc.). Same single origin as the frontend.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_domain"
    },
    {
      "key": "ETL_SERVICE",
      "service": "backend",
      "description": "Document parsing engine: DOCLING (local, no key), UNSTRUCTURED, or LLAMACLOUD (require API keys). Must match the frontend.",
      "secret": false,
      "strategy": "default",
      "default": "DOCLING"
    },
    {
      "key": "STT_SERVICE",
      "service": "backend",
      "description": "Speech-to-text engine (local Faster-Whisper base model; e.g. openai/whisper-1 also works).",
      "secret": false,
      "strategy": "default",
      "default": "local/base"
    },
    {
      "key": "TTS_SERVICE",
      "service": "backend",
      "description": "Text-to-speech engine (local Kokoro by default; any LiteLLM provider works).",
      "secret": false,
      "strategy": "default",
      "default": "local/kokoro"
    },
    {
      "key": "DATABASE_URL",
      "service": "backend",
      "description": "Async SQLAlchemy connection URL to the pgvector database over private networking.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "SERVICE_ROLE",
      "service": "backend",
      "description": "Entrypoint mode: run Alembic migrations, then start the FastAPI API, Celery worker, and Celery beat in this one container.",
      "secret": false,
      "strategy": "default",
      "default": "all"
    },
    {
      "key": "REDIS_APP_URL",
      "service": "backend",
      "description": "Application-level Redis cache and locks.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "reference_variable"
    },
    {
      "key": "SURFSENSE_ENV",
      "service": "backend",
      "description": "Deployment environment flag.",
      "secret": false,
      "strategy": "default",
      "default": "production"
    },
    {
      "key": "DEPLOYMENT_MODE",
      "service": "backend",
      "description": "self-hosted enables self-hosted behavior (local connectors visible, billing off). Must match the frontend.",
      "secret": false,
      "strategy": "default",
      "default": "self-hosted"
    },
    {
      "key": "EMBEDDING_MODEL",
      "service": "backend",
      "description": "Embedding model for vector search. Local sentence-transformers by default; openai://... or cohere://... also supported with API keys.",
      "secret": false,
      "strategy": "default",
      "default": "sentence-transformers/all-MiniLM-L6-v2"
    },
    {
      "key": "CELERY_BROKER_URL",
      "service": "backend",
      "description": "Celery task broker (Redis).",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "reference_variable"
    },
    {
      "key": "MIGRATION_TIMEOUT",
      "service": "backend",
      "description": "Seconds allowed for `alembic upgrade head` before the entrypoint aborts.",
      "secret": false,
      "strategy": "default",
      "default": "900"
    },
    {
      "key": "NEXT_FRONTEND_URL",
      "service": "backend",
      "description": "Public origin of the app, used for CORS and links. Points at the Caddy proxy (single origin).",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_domain"
    },
    {
      "key": "CELERY_RESULT_BACKEND",
      "service": "backend",
      "description": "Celery result store (Redis).",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "reference_variable"
    },
    {
      "key": "FILE_STORAGE_LOCAL_PATH",
      "service": "backend",
      "description": "Object-store path; matches the volume mount so uploaded files persist.",
      "secret": false,
      "strategy": "default",
      "default": "/app/.local_object_store"
    },
    {
      "key": "SURFSENSE_ENABLE_SKILLS",
      "service": "backend",
      "description": "Chat-agent feature flag (upstream production default).",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "CELERY_TASK_DEFAULT_QUEUE",
      "service": "backend",
      "description": "Default Celery queue name; the worker also consumes the .connectors and .gateway sub-queues.",
      "secret": false,
      "strategy": "default",
      "default": "surfsense"
    },
    {
      "key": "SURFSENSE_ENABLE_DOOM_LOOP",
      "service": "backend",
      "description": "Chat-agent feature flag (upstream production default).",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "SURFSENSE_ENABLE_ACTION_LOG",
      "service": "backend",
      "description": "Chat-agent feature flag (upstream production default).",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "SURFSENSE_ENABLE_BUSY_MUTEX",
      "service": "backend",
      "description": "Chat-agent feature flag (upstream production default).",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "SURFSENSE_ENABLE_PERMISSION",
      "service": "backend",
      "description": "Chat-agent feature flag (upstream production default).",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "SURFSENSE_ENABLE_RETRY_AFTER",
      "service": "backend",
      "description": "Chat-agent feature flag (upstream production default).",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "SURFSENSE_ENABLE_REVERT_ROUTE",
      "service": "backend",
      "description": "Chat-agent feature flag (upstream production default).",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "UNSTRUCTURED_HAS_PATCHED_LOOP",
      "service": "backend",
      "description": "Marks the unstructured event-loop patch as applied (mirrors upstream compose).",
      "secret": false,
      "strategy": "default",
      "default": "1"
    },
    {
      "key": "SURFSENSE_ENABLE_COMPACTION_V2",
      "service": "backend",
      "description": "Chat-agent feature flag (upstream production default).",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "SURFSENSE_ENABLE_MODEL_FALLBACK",
      "service": "backend",
      "description": "Chat-agent feature flag (upstream production default).",
      "secret": false,
      "strategy": "default",
      "default": "false"
    },
    {
      "key": "SURFSENSE_ENABLE_CONTEXT_EDITING",
      "service": "backend",
      "description": "Chat-agent feature flag (upstream production default).",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "SURFSENSE_ENABLE_TOOL_CALL_LIMIT",
      "service": "backend",
      "description": "Chat-agent feature flag (upstream production default).",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "SURFSENSE_ENABLE_MODEL_CALL_LIMIT",
      "service": "backend",
      "description": "Chat-agent feature flag (upstream production default).",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "SURFSENSE_ENABLE_TOOL_CALL_REPAIR",
      "service": "backend",
      "description": "Chat-agent feature flag (upstream production default).",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS",
      "service": "backend",
      "description": "Chat-agent feature flag (upstream production default).",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "PORT",
      "service": "proxy",
      "description": "Port Caddy listens on; the service's public domain targets this port.",
      "secret": false,
      "strategy": "default",
      "default": "8080"
    },
    {
      "key": "CADDYFILE",
      "service": "proxy",
      "description": "Full Caddy config, written to /etc/caddy/Caddyfile by the start command. Routes /auth, /users, /api/v1 to the backend, /zero/* to zero-cache, everything else to the frontend. Uses {$VAR} placeholders resolved from the variables below.",
      "secret": false,
      "strategy": "default",
      "default": "{\n\tadmin off\n\tauto_https off\n\tservers {\n\t\tclient_ip_headers X-Forwarded-For X-Real-IP\n\t\ttrusted_proxies static 0.0.0.0/0\n\t}\n}\n\n:{$PORT} {\n\trequest_body {\n\t\tmax_size {$SURFSENSE_MAX_BODY_SIZE:5GB}\n\t}\n\n\t# Railway healthcheck — answered by Caddy itself, no upstream involved.\n\trespond /proxy-healthz 200\n\n\t# Frontend-owned auth page (post-login token handler).\n\treverse_proxy /auth/callback* {$FRONTEND_HOST}\n\n\t# Backend auth + user routes (FastAPI Users + OAuth helpers).\n\treverse_proxy /auth/* {$BACKEND_HOST}\n\treverse_proxy /users/* {$BACKEND_HOST}\n\n\t# Backend REST, streaming, connector OAuth, messaging gateway.\n\treverse_proxy /api/v1/* {$BACKEND_HOST} {\n\t\tflush_interval -1\n\t}\n\n\t# Zero sync auth context lives on the backend.\n\treverse_proxy /zero/context {$BACKEND_HOST}\n\n\t# Zero sync engine (websockets).\n\treverse_proxy /zero/* {$ZERO_HOST}\n\n\t# Next.js app and frontend-owned API routes.\n\treverse_proxy /* {$FRONTEND_HOST}\n}\n"
    },
    {
      "key": "ZERO_HOST",
      "service": "proxy",
      "description": "Upstream for Zero sync websocket traffic.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "BACKEND_HOST",
      "service": "proxy",
      "description": "Upstream for auth, user, and REST/streaming API routes.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "FRONTEND_HOST",
      "service": "proxy",
      "description": "Upstream for app pages and frontend-owned API routes.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "SURFSENSE_MAX_BODY_SIZE",
      "service": "proxy",
      "description": "Request body limit (large document uploads).",
      "secret": false,
      "strategy": "default",
      "default": "5GB"
    },
    {
      "key": "REDISHOST",
      "service": "redis",
      "description": "Private-network hostname of this Redis instance.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "REDISPORT",
      "service": "redis",
      "description": "Redis port.",
      "secret": false,
      "strategy": "default",
      "default": "6379"
    },
    {
      "key": "REDISUSER",
      "service": "redis",
      "description": "Redis ACL user.",
      "secret": false,
      "strategy": "default",
      "default": "default"
    },
    {
      "key": "REDIS_URL",
      "service": "redis",
      "description": "Private-network connection URL. Consumed by the backend as ${{redis.REDIS_URL}}.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "reference_variable"
    },
    {
      "key": "REDISPASSWORD",
      "service": "redis",
      "description": "Alias of REDIS_PASSWORD (Railway convention).",
      "secret": true,
      "strategy": "railway_provided",
      "railway_source": "reference_variable"
    },
    {
      "key": "REDIS_PASSWORD",
      "service": "redis",
      "description": "Generated Redis password.",
      "secret": true,
      "strategy": "generate",
      "generate": "strong_password"
    },
    {
      "key": "REDIS_PUBLIC_URL",
      "service": "redis",
      "description": "Public connection URL via Railway's TCP proxy (only valid if a TCP proxy is enabled).",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "reference_variable"
    }
  ],
  "deploy": {
    "mcp": {
      "server": "railway",
      "tool": "deploy_template",
      "args": {
        "template_code": "surfsense"
      }
    },
    "cli": "railway deploy --template surfsense",
    "api": {
      "method": "POST",
      "path": "/graphql/v2",
      "body": {
        "query": "mutation templateDeploy($input: TemplateDeployV2Input!) { templateDeployV2(input: $input) { projectId workflowId } }",
        "variables": {
          "input": {
            "templateId": "1b1ab3a5-0995-4864-b55c-50509f9c8f10",
            "serializedConfig": {
              "buckets": {},
              "services": {
                "092e81de-20e5-4abb-ba8e-7a78f3dba9cb": {
                  "icon": "https://cdn.jsdelivr.net/gh/MODSetter/SurfSense@main/surfsense_web/public/icon-128.svg",
                  "name": "frontend",
                  "deploy": {
                    "startCommand": null,
                    "healthcheckPath": null,
                    "restartPolicyType": "ON_FAILURE",
                    "restartPolicyMaxRetries": 10
                  },
                  "source": {
                    "image": "ghcr.io/modsetter/surfsense-web:latest"
                  },
                  "variables": {
                    "PORT": {
                      "isOptional": false,
                      "description": "Port the Next.js standalone server listens on.",
                      "defaultValue": "3000"
                    },
                    "AUTH_TYPE": {
                      "isOptional": false,
                      "description": "Must match the backend's AUTH_TYPE.",
                      "defaultValue": "LOCAL"
                    },
                    "ETL_SERVICE": {
                      "isOptional": false,
                      "description": "Must match the backend's ETL_SERVICE (drives upload UI hints).",
                      "defaultValue": "DOCLING"
                    },
                    "DEPLOYMENT_MODE": {
                      "isOptional": false,
                      "description": "Must match the backend's DEPLOYMENT_MODE.",
                      "defaultValue": "self-hosted"
                    },
                    "ZERO_QUERY_API_KEY": {
                      "isOptional": false,
                      "description": "Shared secret validating requests from zero-cache; references zero-cache's generated value so they always match.",
                      "defaultValue": "${{zero-cache.ZERO_QUERY_API_KEY}}"
                    },
                    "SURFSENSE_BACKEND_INTERNAL_URL": {
                      "isOptional": false,
                      "description": "Backend URL for Next.js server-side calls over private networking (browser calls go same-origin through the proxy).",
                      "defaultValue": "http://${{backend.RAILWAY_PRIVATE_DOMAIN}}:8000"
                    }
                  }
                },
                "172cd345-f49a-42cc-9667-4e7a00cc1559": {
                  "icon": "https://devicons.railway.app/railway",
                  "name": "zero-cache",
                  "deploy": {
                    "startCommand": null,
                    "healthcheckPath": null,
                    "restartPolicyType": "ALWAYS",
                    "restartPolicyMaxRetries": 10
                  },
                  "source": {
                    "image": "rocicorp/zero:1.6.0"
                  },
                  "variables": {
                    "PORT": {
                      "isOptional": false,
                      "description": "Informational; Zero listens on 4848 regardless.",
                      "defaultValue": "4848"
                    },
                    "ZERO_CVR_DB": {
                      "isOptional": false,
                      "description": "Client-view-record database (same Postgres instance).",
                      "defaultValue": "postgresql://surfsense:${{db.POSTGRES_PASSWORD}}@${{db.RAILWAY_PRIVATE_DOMAIN}}:5432/surfsense?sslmode=disable"
                    },
                    "ZERO_CHANGE_DB": {
                      "isOptional": false,
                      "description": "Change-log database (same Postgres instance).",
                      "defaultValue": "postgresql://surfsense:${{db.POSTGRES_PASSWORD}}@${{db.RAILWAY_PRIVATE_DOMAIN}}:5432/surfsense?sslmode=disable"
                    },
                    "ZERO_QUERY_URL": {
                      "isOptional": false,
                      "description": "Frontend endpoint that resolves Zero queries (delegates auth to the app).",
                      "defaultValue": "http://${{frontend.RAILWAY_PRIVATE_DOMAIN}}:3000/api/zero/query"
                    },
                    "ZERO_AUTO_RESET": {
                      "isOptional": false,
                      "description": "If replication halts, wipe and re-sync the local replica without touching Postgres.",
                      "defaultValue": "true"
                    },
                    "ZERO_MUTATE_URL": {
                      "isOptional": false,
                      "description": "Required by Zero when auth tokens are used; SurfSense's mutate endpoint is a no-op.",
                      "defaultValue": "http://${{frontend.RAILWAY_PRIVATE_DOMAIN}}:3000/api/zero/mutate"
                    },
                    "ZERO_UPSTREAM_DB": {
                      "isOptional": false,
                      "description": "Upstream Postgres that zero-cache replicates from via logical replication.",
                      "defaultValue": "postgresql://surfsense:${{db.POSTGRES_PASSWORD}}@${{db.RAILWAY_PRIVATE_DOMAIN}}:5432/surfsense?sslmode=disable"
                    },
                    "ZERO_REPLICA_FILE": {
                      "isOptional": false,
                      "description": "SQLite replica location, on the volume so restarts don't re-sync from scratch.",
                      "defaultValue": "/data/zero.db"
                    },
                    "ZERO_CVR_MAX_CONNS": {
                      "isOptional": false,
                      "description": "Max CVR Postgres connections; must be >= ZERO_NUM_SYNC_WORKERS.",
                      "defaultValue": "30"
                    },
                    "ZERO_QUERY_API_KEY": {
                      "isOptional": false,
                      "description": "Generated shared secret for the zero-cache -> /api/zero/query hop. The frontend references this value.",
                      "defaultValue": "{{ZERO_QUERY_API_KEY}}"
                    },
                    "ZERO_ADMIN_PASSWORD": {
                      "isOptional": false,
                      "description": "Generated password protecting Zero's admin endpoints.",
                      "defaultValue": "{{ZERO_ADMIN_PASSWORD}}"
                    },
                    "ZERO_APP_PUBLICATIONS": {
                      "isOptional": false,
                      "description": "Postgres publication to replicate; created by SurfSense's Alembic migrations on first backend boot.",
                      "defaultValue": "zero_publication"
                    },
                    "ZERO_NUM_SYNC_WORKERS": {
                      "isOptional": false,
                      "description": "Sync worker count (the image defaults to CPU cores, which can exceed connection pool limits).",
                      "defaultValue": "4"
                    },
                    "ZERO_UPSTREAM_MAX_CONNS": {
                      "isOptional": false,
                      "description": "Max upstream Postgres connections; must be >= ZERO_NUM_SYNC_WORKERS.",
                      "defaultValue": "20"
                    },
                    "ZERO_QUERY_FORWARD_COOKIES": {
                      "isOptional": false,
                      "description": "Forward browser session cookies on the zero-cache -> query-route hop.",
                      "defaultValue": "true"
                    },
                    "ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS": {
                      "isOptional": false,
                      "description": "How often open sockets re-check auth revocation.",
                      "defaultValue": "60"
                    },
                    "ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS": {
                      "isOptional": false,
                      "description": "How often open sockets re-apply RBAC membership changes.",
                      "defaultValue": "60"
                    }
                  },
                  "volumeMounts": {
                    "172cd345-f49a-42cc-9667-4e7a00cc1559": {
                      "mountPath": "/data"
                    }
                  }
                },
                "7cf139db-02fe-4558-9293-5d18e41a3d0b": {
                  "icon": "https://devicons.railway.app/postgres",
                  "name": "db",
                  "deploy": {
                    "startCommand": "docker-entrypoint.sh postgres -c wal_level=logical -c max_replication_slots=10 -c max_wal_senders=10 -c max_connections=200 -c shared_buffers=256MB",
                    "healthcheckPath": null,
                    "restartPolicyType": "ON_FAILURE",
                    "restartPolicyMaxRetries": 10
                  },
                  "source": {
                    "image": "pgvector/pgvector:pg17"
                  },
                  "variables": {
                    "PGDATA": {
                      "isOptional": false,
                      "description": "Subdirectory of the volume mount so initdb tolerates the volume's lost+found.",
                      "defaultValue": "/var/lib/postgresql/data/pgdata"
                    },
                    "POSTGRES_DB": {
                      "isOptional": false,
                      "description": "Database created on first boot.",
                      "defaultValue": "surfsense"
                    },
                    "POSTGRES_USER": {
                      "isOptional": false,
                      "description": "Database superuser created on first boot.",
                      "defaultValue": "surfsense"
                    },
                    "POSTGRES_PASSWORD": {
                      "isOptional": false,
                      "description": "Generated password for POSTGRES_USER. Referenced by the backend and zero-cache connection URLs.",
                      "defaultValue": "{{POSTGRES_PASSWORD}}"
                    }
                  },
                  "volumeMounts": {
                    "7cf139db-02fe-4558-9293-5d18e41a3d0b": {
                      "mountPath": "/var/lib/postgresql/data"
                    }
                  }
                },
                "ac9508f1-58ef-4c93-a623-11dbc66a0bec": {
                  "icon": "https://cdn.jsdelivr.net/gh/MODSetter/SurfSense@main/surfsense_web/public/icon-128.svg",
                  "name": "backend",
                  "deploy": {
                    "startCommand": null,
                    "healthcheckPath": "/ready",
                    "restartPolicyType": "ON_FAILURE",
                    "healthcheckTimeout": 900,
                    "restartPolicyMaxRetries": 10
                  },
                  "source": {
                    "image": "ghcr.io/modsetter/surfsense-backend:latest"
                  },
                  "variables": {
                    "PORT": {
                      "isOptional": false,
                      "description": "Port Railway probes for the /ready healthcheck; uvicorn listens on 8000. Do not set UVICORN_HOST=:: (Railway probes over IPv4).",
                      "defaultValue": "8000"
                    },
                    "AUTH_TYPE": {
                      "isOptional": false,
                      "description": "Authentication mode: LOCAL (email/password) or GOOGLE (OAuth, requires GOOGLE_OAUTH_CLIENT_ID/_SECRET). Must match the frontend's AUTH_TYPE.",
                      "defaultValue": "LOCAL"
                    },
                    "SECRET_KEY": {
                      "isOptional": false,
                      "description": "Generated JWT / session signing secret (FastAPI Users).",
                      "defaultValue": "{{SECRET_KEY}}"
                    },
                    "BACKEND_URL": {
                      "isOptional": false,
                      "description": "Public URL the backend advertises for itself (OAuth callbacks etc.). Same single origin as the frontend.",
                      "defaultValue": "https://${{proxy.RAILWAY_PUBLIC_DOMAIN}}"
                    },
                    "ETL_SERVICE": {
                      "isOptional": false,
                      "description": "Document parsing engine: DOCLING (local, no key), UNSTRUCTURED, or LLAMACLOUD (require API keys). Must match the frontend.",
                      "defaultValue": "DOCLING"
                    },
                    "STT_SERVICE": {
                      "isOptional": false,
                      "description": "Speech-to-text engine (local Faster-Whisper base model; e.g. openai/whisper-1 also works).",
                      "defaultValue": "local/base"
                    },
                    "TTS_SERVICE": {
                      "isOptional": false,
                      "description": "Text-to-speech engine (local Kokoro by default; any LiteLLM provider works).",
                      "defaultValue": "local/kokoro"
                    },
                    "DATABASE_URL": {
                      "isOptional": false,
                      "description": "Async SQLAlchemy connection URL to the pgvector database over private networking.",
                      "defaultValue": "postgresql+asyncpg://surfsense:${{db.POSTGRES_PASSWORD}}@${{db.RAILWAY_PRIVATE_DOMAIN}}:5432/surfsense"
                    },
                    "SERVICE_ROLE": {
                      "isOptional": false,
                      "description": "Entrypoint mode: run Alembic migrations, then start the FastAPI API, Celery worker, and Celery beat in this one container.",
                      "defaultValue": "all"
                    },
                    "REDIS_APP_URL": {
                      "isOptional": false,
                      "description": "Application-level Redis cache and locks.",
                      "defaultValue": "${{redis.REDIS_URL}}"
                    },
                    "SURFSENSE_ENV": {
                      "isOptional": false,
                      "description": "Deployment environment flag.",
                      "defaultValue": "production"
                    },
                    "DEPLOYMENT_MODE": {
                      "isOptional": false,
                      "description": "self-hosted enables self-hosted behavior (local connectors visible, billing off). Must match the frontend.",
                      "defaultValue": "self-hosted"
                    },
                    "EMBEDDING_MODEL": {
                      "isOptional": false,
                      "description": "Embedding model for vector search. Local sentence-transformers by default; openai://... or cohere://... also supported with API keys.",
                      "defaultValue": "sentence-transformers/all-MiniLM-L6-v2"
                    },
                    "CELERY_BROKER_URL": {
                      "isOptional": false,
                      "description": "Celery task broker (Redis).",
                      "defaultValue": "${{redis.REDIS_URL}}"
                    },
                    "MIGRATION_TIMEOUT": {
                      "isOptional": false,
                      "description": "Seconds allowed for `alembic upgrade head` before the entrypoint aborts.",
                      "defaultValue": "900"
                    },
                    "NEXT_FRONTEND_URL": {
                      "isOptional": false,
                      "description": "Public origin of the app, used for CORS and links. Points at the Caddy proxy (single origin).",
                      "defaultValue": "https://${{proxy.RAILWAY_PUBLIC_DOMAIN}}"
                    },
                    "CELERY_RESULT_BACKEND": {
                      "isOptional": false,
                      "description": "Celery result store (Redis).",
                      "defaultValue": "${{redis.REDIS_URL}}"
                    },
                    "FILE_STORAGE_LOCAL_PATH": {
                      "isOptional": false,
                      "description": "Object-store path; matches the volume mount so uploaded files persist.",
                      "defaultValue": "/app/.local_object_store"
                    },
                    "SURFSENSE_ENABLE_SKILLS": {
                      "isOptional": false,
                      "description": "Chat-agent feature flag (upstream production default).",
                      "defaultValue": "true"
                    },
                    "CELERY_TASK_DEFAULT_QUEUE": {
                      "isOptional": false,
                      "description": "Default Celery queue name; the worker also consumes the .connectors and .gateway sub-queues.",
                      "defaultValue": "surfsense"
                    },
                    "SURFSENSE_ENABLE_DOOM_LOOP": {
                      "isOptional": false,
                      "description": "Chat-agent feature flag (upstream production default).",
                      "defaultValue": "true"
                    },
                    "SURFSENSE_ENABLE_ACTION_LOG": {
                      "isOptional": false,
                      "description": "Chat-agent feature flag (upstream production default).",
                      "defaultValue": "true"
                    },
                    "SURFSENSE_ENABLE_BUSY_MUTEX": {
                      "isOptional": false,
                      "description": "Chat-agent feature flag (upstream production default).",
                      "defaultValue": "true"
                    },
                    "SURFSENSE_ENABLE_PERMISSION": {
                      "isOptional": false,
                      "description": "Chat-agent feature flag (upstream production default).",
                      "defaultValue": "true"
                    },
                    "SURFSENSE_ENABLE_RETRY_AFTER": {
                      "isOptional": false,
                      "description": "Chat-agent feature flag (upstream production default).",
                      "defaultValue": "true"
                    },
                    "SURFSENSE_ENABLE_REVERT_ROUTE": {
                      "isOptional": false,
                      "description": "Chat-agent feature flag (upstream production default).",
                      "defaultValue": "true"
                    },
                    "UNSTRUCTURED_HAS_PATCHED_LOOP": {
                      "isOptional": false,
                      "description": "Marks the unstructured event-loop patch as applied (mirrors upstream compose).",
                      "defaultValue": "1"
                    },
                    "SURFSENSE_ENABLE_COMPACTION_V2": {
                      "isOptional": false,
                      "description": "Chat-agent feature flag (upstream production default).",
                      "defaultValue": "true"
                    },
                    "SURFSENSE_ENABLE_MODEL_FALLBACK": {
                      "isOptional": false,
                      "description": "Chat-agent feature flag (upstream production default).",
                      "defaultValue": "false"
                    },
                    "SURFSENSE_ENABLE_CONTEXT_EDITING": {
                      "isOptional": false,
                      "description": "Chat-agent feature flag (upstream production default).",
                      "defaultValue": "true"
                    },
                    "SURFSENSE_ENABLE_TOOL_CALL_LIMIT": {
                      "isOptional": false,
                      "description": "Chat-agent feature flag (upstream production default).",
                      "defaultValue": "true"
                    },
                    "SURFSENSE_ENABLE_MODEL_CALL_LIMIT": {
                      "isOptional": false,
                      "description": "Chat-agent feature flag (upstream production default).",
                      "defaultValue": "true"
                    },
                    "SURFSENSE_ENABLE_TOOL_CALL_REPAIR": {
                      "isOptional": false,
                      "description": "Chat-agent feature flag (upstream production default).",
                      "defaultValue": "true"
                    },
                    "SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS": {
                      "isOptional": false,
                      "description": "Chat-agent feature flag (upstream production default).",
                      "defaultValue": "true"
                    }
                  },
                  "volumeMounts": {
                    "ac9508f1-58ef-4c93-a623-11dbc66a0bec": {
                      "mountPath": "/app/.local_object_store"
                    }
                  }
                },
                "dd914245-6b7f-4439-8bef-43a9ba420d74": {
                  "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/svg/caddy.svg",
                  "name": "proxy",
                  "deploy": {
                    "startCommand": "sh -c 'printf \"%s\" \"$CADDYFILE\" > /etc/caddy/Caddyfile && exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile'",
                    "healthcheckPath": "/proxy-healthz",
                    "restartPolicyType": "ON_FAILURE",
                    "restartPolicyMaxRetries": 10
                  },
                  "source": {
                    "image": "caddy:2-alpine"
                  },
                  "variables": {
                    "PORT": {
                      "isOptional": false,
                      "description": "Port Caddy listens on; the service's public domain targets this port.",
                      "defaultValue": "8080"
                    },
                    "CADDYFILE": {
                      "isOptional": false,
                      "description": "Full Caddy config, written to /etc/caddy/Caddyfile by the start command. Routes /auth, /users, /api/v1 to the backend, /zero/* to zero-cache, everything else to the frontend. Uses {$VAR} placeholders resolved from the variables below.",
                      "defaultValue": "{\n\tadmin off\n\tauto_https off\n\tservers {\n\t\tclient_ip_headers X-Forwarded-For X-Real-IP\n\t\ttrusted_proxies static 0.0.0.0/0\n\t}\n}\n\n:{$PORT} {\n\trequest_body {\n\t\tmax_size {$SURFSENSE_MAX_BODY_SIZE:5GB}\n\t}\n\n\t# Railway healthcheck — answered by Caddy itself, no upstream involved.\n\trespond /proxy-healthz 200\n\n\t# Frontend-owned auth page (post-login token handler).\n\treverse_proxy /auth/callback* {$FRONTEND_HOST}\n\n\t# Backend auth + user routes (FastAPI Users + OAuth helpers).\n\treverse_proxy /auth/* {$BACKEND_HOST}\n\treverse_proxy /users/* {$BACKEND_HOST}\n\n\t# Backend REST, streaming, connector OAuth, messaging gateway.\n\treverse_proxy /api/v1/* {$BACKEND_HOST} {\n\t\tflush_interval -1\n\t}\n\n\t# Zero sync auth context lives on the backend.\n\treverse_proxy /zero/context {$BACKEND_HOST}\n\n\t# Zero sync engine (websockets).\n\treverse_proxy /zero/* {$ZERO_HOST}\n\n\t# Next.js app and frontend-owned API routes.\n\treverse_proxy /* {$FRONTEND_HOST}\n}\n"
                    },
                    "ZERO_HOST": {
                      "isOptional": false,
                      "description": "Upstream for Zero sync websocket traffic.",
                      "defaultValue": "${{zero-cache.RAILWAY_PRIVATE_DOMAIN}}:4848"
                    },
                    "BACKEND_HOST": {
                      "isOptional": false,
                      "description": "Upstream for auth, user, and REST/streaming API routes.",
                      "defaultValue": "${{backend.RAILWAY_PRIVATE_DOMAIN}}:8000"
                    },
                    "FRONTEND_HOST": {
                      "isOptional": false,
                      "description": "Upstream for app pages and frontend-owned API routes.",
                      "defaultValue": "${{frontend.RAILWAY_PRIVATE_DOMAIN}}:3000"
                    },
                    "SURFSENSE_MAX_BODY_SIZE": {
                      "isOptional": false,
                      "description": "Request body limit (large document uploads).",
                      "defaultValue": "5GB"
                    }
                  },
                  "networking": {
                    "serviceDomains": {
                      "<hasDomain>": {
                        "port": 8080
                      }
                    }
                  }
                },
                "f58584f2-e8ce-4c51-bc7a-ec7cd50a3ad4": {
                  "icon": "https://cdn.sanity.io/images/sy1jschh/production/0ce0bfdcfbdbf69662b1116671f97c2dd788b655-157x157.svg",
                  "name": "redis",
                  "deploy": {
                    "startCommand": "/bin/sh -c \"rm -rf $RAILWAY_VOLUME_MOUNT_PATH/lost+found/ && exec docker-entrypoint.sh redis-server --requirepass $REDIS_PASSWORD --save 60 1 --dir $RAILWAY_VOLUME_MOUNT_PATH\"",
                    "healthcheckPath": null,
                    "restartPolicyType": "ON_FAILURE",
                    "restartPolicyMaxRetries": 10
                  },
                  "source": {
                    "image": "redis:8.2.1"
                  },
                  "variables": {
                    "REDISHOST": {
                      "isOptional": false,
                      "description": "Private-network hostname of this Redis instance.",
                      "defaultValue": "${{RAILWAY_PRIVATE_DOMAIN}}"
                    },
                    "REDISPORT": {
                      "isOptional": false,
                      "description": "Redis port.",
                      "defaultValue": "6379"
                    },
                    "REDISUSER": {
                      "isOptional": false,
                      "description": "Redis ACL user.",
                      "defaultValue": "default"
                    },
                    "REDIS_URL": {
                      "isOptional": false,
                      "description": "Private-network connection URL. Consumed by the backend as ${{redis.REDIS_URL}}.",
                      "defaultValue": "redis://${{REDISUSER}}:${{REDIS_PASSWORD}}@${{REDISHOST}}:${{REDISPORT}}"
                    },
                    "REDISPASSWORD": {
                      "isOptional": false,
                      "description": "Alias of REDIS_PASSWORD (Railway convention).",
                      "defaultValue": "${{REDIS_PASSWORD}}"
                    },
                    "REDIS_PASSWORD": {
                      "isOptional": false,
                      "description": "Generated Redis password.",
                      "defaultValue": "{{REDIS_PASSWORD}}"
                    },
                    "REDIS_PUBLIC_URL": {
                      "isOptional": false,
                      "description": "Public connection URL via Railway's TCP proxy (only valid if a TCP proxy is enabled).",
                      "defaultValue": "redis://default:${{REDIS_PASSWORD}}@${{RAILWAY_TCP_PROXY_DOMAIN}}:${{RAILWAY_TCP_PROXY_PORT}}"
                    }
                  },
                  "volumeMounts": {
                    "f58584f2-e8ce-4c51-bc7a-ec7cd50a3ad4": {
                      "mountPath": "/data"
                    }
                  },
                  "haTemplateCode": "redis-ha",
                  "haConversionConfig": {
                    "edge": {
                      "label": "Reverse Proxies",
                      "options": [
                        1,
                        2
                      ],
                      "nodeLabel": "HAProxy",
                      "description": "HAProxy instances routing writes to the primary",
                      "defaultValue": 2
                    },
                    "replica": {
                      "label": "Redis Replicas",
                      "options": [
                        2,
                        3,
                        4,
                        5
                      ],
                      "nodeLabel": "Redis",
                      "description": "Nodes running Redis + colocated Sentinel",
                      "defaultValue": 2
                    },
                    "internal": null,
                    "description": "This will convert your Redis instance to an HA cluster with Sentinel failover and HAProxy routing to the primary."
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "post_deploy": {
    "healthcheck": {
      "service": "proxy",
      "method": "GET",
      "path": "/proxy-healthz",
      "expect_status": 200
    }
  },
  "resources": {
    "expected_services": 6,
    "needs_volume": true
  },
  "generated_at": "2026-09-10T16:14:52.212Z",
  "generator_version": "0.1.0",
  "status": "validated",
  "validated_at": "2026-09-09T01:48:22.713Z",
  "success_rate_30d": 1,
  "validation": {
    "last_run_id": "run_a258f737d8a94c1d83d2",
    "checks": [
      {
        "name": "workflow_completed",
        "passed": true
      },
      {
        "name": "all_services_deployed",
        "passed": true
      },
      {
        "name": "healthcheck",
        "passed": true
      },
      {
        "name": "stays_up",
        "passed": true
      }
    ],
    "typical_ready_seconds": 166,
    "typical_build_seconds": 0,
    "typical_start_seconds": 112,
    "slowest_service": "backend"
  }
}
