{
  "manifest_version": "1.0.0",
  "template": {
    "id": "460db22b-493c-41a3-9b81-da883062acd4",
    "slug": "open-notebook-kokoro",
    "name": "Open Notebook + Kokoro",
    "description": "Self-hosted NotebookLM: Open Notebook with free Kokoro podcast TTS",
    "url": "https://railway.com/deploy/open-notebook-kokoro",
    "upstream": {
      "image": "lfnovo/open_notebook:1.15.1"
    }
  },
  "status": "unvalidated",
  "validated_at": null,
  "success_rate_30d": null,
  "services": [
    {
      "name": "SurrealDB",
      "source": {
        "image": "surrealdb/surrealdb:v2.7.0"
      },
      "needs_volume": true,
      "volume_mount_path": "/data",
      "http": false
    },
    {
      "name": "Open Notebook",
      "source": {
        "image": "lfnovo/open_notebook:1.15.1"
      },
      "needs_volume": true,
      "volume_mount_path": "/app/data",
      "http": true
    },
    {
      "name": "Kokoro",
      "source": {
        "image": "ghcr.io/remsky/kokoro-fastapi-cpu:v0.9.2"
      },
      "needs_volume": false,
      "http": false
    }
  ],
  "required_inputs": [
    {
      "key": "PORT",
      "service": "SurrealDB",
      "description": "SurrealDB port",
      "secret": false,
      "strategy": "default",
      "default": "8000"
    },
    {
      "key": "SURREAL_LOG",
      "service": "SurrealDB",
      "description": "Log level",
      "secret": false,
      "strategy": "default",
      "default": "info"
    },
    {
      "key": "SURREAL_BIND",
      "service": "SurrealDB",
      "description": "Address SurrealDB listens on, IPv4 and IPv6",
      "secret": false,
      "strategy": "default",
      "default": "[::]:8000"
    },
    {
      "key": "SURREAL_PASS",
      "service": "SurrealDB",
      "description": "SurrealDB root password (generated)",
      "secret": false,
      "strategy": "generate",
      "generate": "random_base64_32"
    },
    {
      "key": "SURREAL_PATH",
      "service": "SurrealDB",
      "description": "RocksDB storage on the volume",
      "secret": false,
      "strategy": "default",
      "default": "rocksdb:/data/open-notebook.db"
    },
    {
      "key": "SURREAL_USER",
      "service": "SurrealDB",
      "description": "SurrealDB root user (generated)",
      "secret": false,
      "strategy": "generate",
      "generate": "random_base64_32"
    },
    {
      "key": "RAILWAY_RUN_UID",
      "service": "SurrealDB",
      "description": "Run as root so SurrealDB can write to the volume",
      "secret": false,
      "strategy": "default",
      "default": "0"
    },
    {
      "key": "PORT",
      "service": "Open Notebook",
      "description": "Port of the web app (the API is reached through it)",
      "secret": false,
      "strategy": "default",
      "default": "8502"
    },
    {
      "key": "API_URL",
      "service": "Open Notebook",
      "description": "Public URL the browser uses for the API (auto-configured)",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_domain"
    },
    {
      "key": "ON_SETUP_PY",
      "service": "Open Notebook",
      "description": "Connects the built-in Kokoro service on first start; skipped once it is set up",
      "secret": false,
      "strategy": "default",
      "default": "import os, sys, time, json, urllib.request, urllib.error\nB = \"http://127.0.0.1:5055/api\"\nK = os.environ.get(\"KOKORO_BASE_URL\", \"\").rstrip(\"/\")\nH = {\"Authorization\": \"Bearer \" + os.environ.get(\"OPEN_NOTEBOOK_PASSWORD\", \"\"), \"Content-Type\": \"application/json\"}\nNAME = \"Kokoro (built-in)\"\nVOICES = {\"alloy\": \"af_alloy\", \"ash\": \"am_adam\", \"echo\": \"am_echo\", \"nova\": \"af_nova\", \"shimmer\": \"af_bella\", \"onyx\": \"am_onyx\", \"fable\": \"bm_fable\", \"coral\": \"af_heart\", \"sage\": \"af_sarah\"}\ndef log(*a): print(\"kokoro-setup:\", *a, flush=True)\ndef call(m, p, body=None, base=B, h=H):\n    r = urllib.request.Request(base + p, data=None if body is None else json.dumps(body).encode(), headers=h, method=m)\n    try:\n        with urllib.request.urlopen(r, timeout=60) as x: return x.status, json.loads(x.read() or b\"null\")\n    except urllib.error.HTTPError as e: return e.code, e.read()[:300].decode(\"utf-8\", \"replace\")\n    except Exception as e: return 0, str(e)[:200]\nif not K: log(\"KOKORO_BASE_URL is empty, skipped\"); sys.exit(0)\nfor i in range(120):\n    s, _ = call(\"GET\", \"/models/defaults\")\n    if s == 200: break\n    time.sleep(5)\nelse: log(\"API not ready after 10 minutes, skipped\"); sys.exit(0)\ns, creds = call(\"GET\", \"/credentials\")\nif s == 200 and any(c.get(\"name\") == NAME for c in creds): log(\"already set up, skipped\"); sys.exit(0)\ns, cred = call(\"POST\", \"/credentials\", {\"name\": NAME, \"provider\": \"openai_compatible\", \"modalities\": [\"text_to_speech\"], \"base_url\": K})\nif s != 201: log(\"create credential failed:\", s, cred); sys.exit(0)\ns, r = call(\"POST\", \"/credentials/%s/register-models\" % cred[\"id\"], {\"models\": [{\"name\": \"kokoro\", \"provider\": \"openai_compatible\", \"model_type\": \"text_to_speech\"}]})\nlog(\"register model:\", s, r)\ns, models = call(\"GET\", \"/models\")\nmid = next((m[\"id\"] for m in (models if s == 200 else []) if m.get(\"name\") == \"kokoro\" and m.get(\"type\") == \"text_to_speech\"), None)\nif not mid: log(\"kokoro model not found\"); sys.exit(0)\ns, d = call(\"GET\", \"/models/defaults\")\nif s == 200 and not d.get(\"default_text_to_speech_model\"):\n    log(\"default TTS model:\", call(\"PUT\", \"/models/defaults\", {\"default_text_to_speech_model\": mid})[0])\ns, v = call(\"GET\", \"/audio/voices\", base=K, h={})\nhave = set(x if isinstance(x, str) else x.get(\"id\") for x in (v.get(\"voices\", []) if s == 200 and isinstance(v, dict) else []))\nlog(\"kokoro voices:\", len(have))\ns, profiles = call(\"GET\", \"/speaker-profiles\")\nfor p in (profiles if s == 200 else []):\n    if p.get(\"voice_model\"): continue\n    sp = []\n    for n, x in enumerate(p[\"speakers\"]):\n        x = dict(x); want = VOICES.get(x.get(\"voice_id\"), (\"af_heart\", \"am_michael\")[n % 2])\n        x[\"voice_id\"] = want if (not have or want in have) else (\"af_heart\", \"am_michael\")[n % 2]\n        sp.append(x)\n    s2, _ = call(\"PUT\", \"/speaker-profiles/%s\" % p[\"id\"], {\"name\": p[\"name\"], \"description\": p.get(\"description\", \"\"), \"voice_model\": mid, \"speakers\": sp})\n    log(\"speaker profile\", p[\"name\"], s2)\nlog(\"done\")\n"
    },
    {
      "key": "SURREAL_URL",
      "service": "Open Notebook",
      "description": "SurrealDB over the private network (auto-configured)",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "CORS_ORIGINS",
      "service": "Open Notebook",
      "description": "Origins allowed to call the API (auto-configured)",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_domain"
    },
    {
      "key": "LOGURU_LEVEL",
      "service": "Open Notebook",
      "description": "Log level of the API and worker",
      "secret": false,
      "strategy": "default",
      "default": "INFO"
    },
    {
      "key": "SURREAL_USER",
      "service": "Open Notebook",
      "description": "SurrealDB user (auto-configured)",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "reference_variable"
    },
    {
      "key": "TTS_BATCH_SIZE",
      "service": "Open Notebook",
      "description": "Text-to-speech requests sent at once per podcast. Kept low for the CPU Kokoro service",
      "secret": false,
      "strategy": "default",
      "default": "2"
    },
    {
      "key": "KOKORO_BASE_URL",
      "service": "Open Notebook",
      "description": "Kokoro text-to-speech over the private network (auto-configured). Registered as the default podcast voice model on first start",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "SURREAL_DATABASE",
      "service": "Open Notebook",
      "description": "SurrealDB database",
      "secret": false,
      "strategy": "default",
      "default": "open_notebook"
    },
    {
      "key": "SURREAL_PASSWORD",
      "service": "Open Notebook",
      "description": "SurrealDB password (auto-configured)",
      "secret": true,
      "strategy": "railway_provided",
      "railway_source": "reference_variable"
    },
    {
      "key": "SURREAL_NAMESPACE",
      "service": "Open Notebook",
      "description": "SurrealDB namespace",
      "secret": false,
      "strategy": "default",
      "default": "open_notebook"
    },
    {
      "key": "ESPERANTO_TTS_TIMEOUT",
      "service": "Open Notebook",
      "description": "Seconds per text-to-speech request",
      "secret": false,
      "strategy": "default",
      "default": "600"
    },
    {
      "key": "OPEN_NOTEBOOK_PASSWORD",
      "service": "Open Notebook",
      "description": "Password for the web app and the REST API (generated). Read it here after deploy",
      "secret": true,
      "strategy": "generate",
      "generate": "strong_password"
    },
    {
      "key": "OPEN_NOTEBOOK_ENCRYPTION_KEY",
      "service": "Open Notebook",
      "description": "Encrypts AI provider keys stored in the database (generated). Don't change it after you save keys",
      "secret": true,
      "strategy": "generate",
      "generate": "random_base64_32"
    },
    {
      "key": "PORT",
      "service": "Kokoro",
      "description": "Port of the Kokoro speech API",
      "secret": false,
      "strategy": "default",
      "default": "8880"
    }
  ],
  "deploy": {
    "mcp": {
      "server": "railway",
      "tool": "deploy_template",
      "args": {
        "template_code": "open-notebook-kokoro"
      }
    },
    "cli": "railway deploy --template open-notebook-kokoro",
    "api": {
      "method": "POST",
      "path": "/graphql/v2",
      "body": {
        "query": "mutation templateDeploy($input: TemplateDeployV2Input!) { templateDeployV2(input: $input) { projectId workflowId } }",
        "variables": {
          "input": {
            "templateId": "460db22b-493c-41a3-9b81-da883062acd4",
            "serializedConfig": {
              "services": {
                "85cb800a-c2fb-4974-ad07-9125c3cce872": {
                  "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/surrealdb.svg",
                  "name": "SurrealDB",
                  "deploy": {
                    "startCommand": "/surreal start --no-banner",
                    "healthcheckPath": "/health",
                    "restartPolicyType": "ON_FAILURE",
                    "healthcheckTimeout": 120,
                    "restartPolicyMaxRetries": 10
                  },
                  "source": {
                    "image": "surrealdb/surrealdb:v2.7.0"
                  },
                  "variables": {
                    "PORT": {
                      "description": "SurrealDB port",
                      "defaultValue": "8000"
                    },
                    "SURREAL_LOG": {
                      "description": "Log level",
                      "defaultValue": "info"
                    },
                    "SURREAL_BIND": {
                      "description": "Address SurrealDB listens on, IPv4 and IPv6",
                      "defaultValue": "[::]:8000"
                    },
                    "SURREAL_PASS": {
                      "description": "SurrealDB root password (generated)",
                      "defaultValue": "{{SURREAL_PASS}}"
                    },
                    "SURREAL_PATH": {
                      "description": "RocksDB storage on the volume",
                      "defaultValue": "rocksdb:/data/open-notebook.db"
                    },
                    "SURREAL_USER": {
                      "description": "SurrealDB root user (generated)",
                      "defaultValue": "{{SURREAL_USER}}"
                    },
                    "RAILWAY_RUN_UID": {
                      "description": "Run as root so SurrealDB can write to the volume",
                      "defaultValue": "0"
                    }
                  },
                  "volumeMounts": {
                    "85cb800a-c2fb-4974-ad07-9125c3cce872": {
                      "mountPath": "/data"
                    }
                  }
                },
                "94725341-f2cf-4c1d-af0b-2033253ddb3d": {
                  "icon": "https://raw.githubusercontent.com/lfnovo/open-notebook/v1.15.1/frontend/public/logo.svg",
                  "name": "Open Notebook",
                  "deploy": {
                    "startCommand": "/bin/bash -c \"printenv ON_SETUP_PY > /tmp/kokoro-setup.py; /app/.venv/bin/python /tmp/kokoro-setup.py & exec /app/scripts/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf\"",
                    "healthcheckPath": "/api/auth/status",
                    "restartPolicyType": "ON_FAILURE",
                    "healthcheckTimeout": 300,
                    "restartPolicyMaxRetries": 10
                  },
                  "source": {
                    "image": "lfnovo/open_notebook:1.15.1"
                  },
                  "variables": {
                    "PORT": {
                      "description": "Port of the web app (the API is reached through it)",
                      "defaultValue": "8502"
                    },
                    "API_URL": {
                      "description": "Public URL the browser uses for the API (auto-configured)",
                      "defaultValue": "https://${{RAILWAY_PUBLIC_DOMAIN}}"
                    },
                    "ON_SETUP_PY": {
                      "description": "Connects the built-in Kokoro service on first start; skipped once it is set up",
                      "defaultValue": "import os, sys, time, json, urllib.request, urllib.error\nB = \"http://127.0.0.1:5055/api\"\nK = os.environ.get(\"KOKORO_BASE_URL\", \"\").rstrip(\"/\")\nH = {\"Authorization\": \"Bearer \" + os.environ.get(\"OPEN_NOTEBOOK_PASSWORD\", \"\"), \"Content-Type\": \"application/json\"}\nNAME = \"Kokoro (built-in)\"\nVOICES = {\"alloy\": \"af_alloy\", \"ash\": \"am_adam\", \"echo\": \"am_echo\", \"nova\": \"af_nova\", \"shimmer\": \"af_bella\", \"onyx\": \"am_onyx\", \"fable\": \"bm_fable\", \"coral\": \"af_heart\", \"sage\": \"af_sarah\"}\ndef log(*a): print(\"kokoro-setup:\", *a, flush=True)\ndef call(m, p, body=None, base=B, h=H):\n    r = urllib.request.Request(base + p, data=None if body is None else json.dumps(body).encode(), headers=h, method=m)\n    try:\n        with urllib.request.urlopen(r, timeout=60) as x: return x.status, json.loads(x.read() or b\"null\")\n    except urllib.error.HTTPError as e: return e.code, e.read()[:300].decode(\"utf-8\", \"replace\")\n    except Exception as e: return 0, str(e)[:200]\nif not K: log(\"KOKORO_BASE_URL is empty, skipped\"); sys.exit(0)\nfor i in range(120):\n    s, _ = call(\"GET\", \"/models/defaults\")\n    if s == 200: break\n    time.sleep(5)\nelse: log(\"API not ready after 10 minutes, skipped\"); sys.exit(0)\ns, creds = call(\"GET\", \"/credentials\")\nif s == 200 and any(c.get(\"name\") == NAME for c in creds): log(\"already set up, skipped\"); sys.exit(0)\ns, cred = call(\"POST\", \"/credentials\", {\"name\": NAME, \"provider\": \"openai_compatible\", \"modalities\": [\"text_to_speech\"], \"base_url\": K})\nif s != 201: log(\"create credential failed:\", s, cred); sys.exit(0)\ns, r = call(\"POST\", \"/credentials/%s/register-models\" % cred[\"id\"], {\"models\": [{\"name\": \"kokoro\", \"provider\": \"openai_compatible\", \"model_type\": \"text_to_speech\"}]})\nlog(\"register model:\", s, r)\ns, models = call(\"GET\", \"/models\")\nmid = next((m[\"id\"] for m in (models if s == 200 else []) if m.get(\"name\") == \"kokoro\" and m.get(\"type\") == \"text_to_speech\"), None)\nif not mid: log(\"kokoro model not found\"); sys.exit(0)\ns, d = call(\"GET\", \"/models/defaults\")\nif s == 200 and not d.get(\"default_text_to_speech_model\"):\n    log(\"default TTS model:\", call(\"PUT\", \"/models/defaults\", {\"default_text_to_speech_model\": mid})[0])\ns, v = call(\"GET\", \"/audio/voices\", base=K, h={})\nhave = set(x if isinstance(x, str) else x.get(\"id\") for x in (v.get(\"voices\", []) if s == 200 and isinstance(v, dict) else []))\nlog(\"kokoro voices:\", len(have))\ns, profiles = call(\"GET\", \"/speaker-profiles\")\nfor p in (profiles if s == 200 else []):\n    if p.get(\"voice_model\"): continue\n    sp = []\n    for n, x in enumerate(p[\"speakers\"]):\n        x = dict(x); want = VOICES.get(x.get(\"voice_id\"), (\"af_heart\", \"am_michael\")[n % 2])\n        x[\"voice_id\"] = want if (not have or want in have) else (\"af_heart\", \"am_michael\")[n % 2]\n        sp.append(x)\n    s2, _ = call(\"PUT\", \"/speaker-profiles/%s\" % p[\"id\"], {\"name\": p[\"name\"], \"description\": p.get(\"description\", \"\"), \"voice_model\": mid, \"speakers\": sp})\n    log(\"speaker profile\", p[\"name\"], s2)\nlog(\"done\")\n"
                    },
                    "SURREAL_URL": {
                      "description": "SurrealDB over the private network (auto-configured)",
                      "defaultValue": "ws://${{SurrealDB.RAILWAY_PRIVATE_DOMAIN}}:8000/rpc"
                    },
                    "CORS_ORIGINS": {
                      "description": "Origins allowed to call the API (auto-configured)",
                      "defaultValue": "https://${{RAILWAY_PUBLIC_DOMAIN}}"
                    },
                    "LOGURU_LEVEL": {
                      "description": "Log level of the API and worker",
                      "defaultValue": "INFO"
                    },
                    "SURREAL_USER": {
                      "description": "SurrealDB user (auto-configured)",
                      "defaultValue": "${{SurrealDB.SURREAL_USER}}"
                    },
                    "TTS_BATCH_SIZE": {
                      "description": "Text-to-speech requests sent at once per podcast. Kept low for the CPU Kokoro service",
                      "defaultValue": "2"
                    },
                    "KOKORO_BASE_URL": {
                      "description": "Kokoro text-to-speech over the private network (auto-configured). Registered as the default podcast voice model on first start",
                      "defaultValue": "http://${{Kokoro.RAILWAY_PRIVATE_DOMAIN}}:8880/v1"
                    },
                    "SURREAL_DATABASE": {
                      "description": "SurrealDB database",
                      "defaultValue": "open_notebook"
                    },
                    "SURREAL_PASSWORD": {
                      "description": "SurrealDB password (auto-configured)",
                      "defaultValue": "${{SurrealDB.SURREAL_PASS}}"
                    },
                    "SURREAL_NAMESPACE": {
                      "description": "SurrealDB namespace",
                      "defaultValue": "open_notebook"
                    },
                    "ESPERANTO_TTS_TIMEOUT": {
                      "description": "Seconds per text-to-speech request",
                      "defaultValue": "600"
                    },
                    "OPEN_NOTEBOOK_PASSWORD": {
                      "description": "Password for the web app and the REST API (generated). Read it here after deploy",
                      "defaultValue": "{{OPEN_NOTEBOOK_PASSWORD}}"
                    },
                    "OPEN_NOTEBOOK_ENCRYPTION_KEY": {
                      "description": "Encrypts AI provider keys stored in the database (generated). Don't change it after you save keys",
                      "defaultValue": "{{OPEN_NOTEBOOK_ENCRYPTION_KEY}}"
                    }
                  },
                  "networking": {
                    "serviceDomains": {
                      "<hasDomain>": {}
                    }
                  },
                  "volumeMounts": {
                    "94725341-f2cf-4c1d-af0b-2033253ddb3d": {
                      "mountPath": "/app/data"
                    }
                  }
                },
                "d1fd57d2-f5c1-4a99-9f8c-91046640eda2": {
                  "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/speaches.svg",
                  "name": "Kokoro",
                  "deploy": {
                    "healthcheckPath": "/health",
                    "restartPolicyType": "ON_FAILURE",
                    "healthcheckTimeout": 300,
                    "restartPolicyMaxRetries": 10
                  },
                  "source": {
                    "image": "ghcr.io/remsky/kokoro-fastapi-cpu:v0.9.2"
                  },
                  "variables": {
                    "PORT": {
                      "description": "Port of the Kokoro speech API",
                      "defaultValue": "8880"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "post_deploy": {
    "healthcheck": {
      "service": "Open Notebook",
      "method": "GET",
      "path": "/api/auth/status",
      "expect_status": 200
    }
  },
  "resources": {
    "expected_services": 3,
    "needs_volume": true
  },
  "generated_at": "2026-11-06T16:15:12.187Z",
  "generator_version": "0.1.0"
}
