{
  "manifest_version": "1.0.0",
  "template": {
    "id": "1a296e7f-c711-43b7-8f68-d66fc940bf9c",
    "slug": "money-printer-turbo",
    "name": "Money Printer Turbo",
    "description": "AI generates HD short videos from a single topic instantly with one click.",
    "url": "https://railway.com/deploy/money-printer-turbo",
    "upstream": {
      "image": "ghcr.io/harry0703/moneyprinterturbo:latest"
    }
  },
  "status": "unvalidated",
  "validated_at": null,
  "success_rate_30d": null,
  "services": [
    {
      "name": "app",
      "source": {
        "image": "ghcr.io/harry0703/moneyprinterturbo:latest"
      },
      "needs_volume": true,
      "volume_mount_path": "/data",
      "http": true
    },
    {
      "name": "proxy",
      "source": {
        "image": "caddy:2-alpine"
      },
      "needs_volume": false,
      "http": false
    }
  ],
  "required_inputs": [
    {
      "key": "PORT",
      "service": "app",
      "description": "Port Streamlit binds. Railway's healthcheck probes it, so leave at 8501.",
      "secret": false,
      "strategy": "default",
      "default": "8501"
    },
    {
      "key": "HIDE_CONFIG",
      "service": "app",
      "description": "Hide the WebUI's settings panel once configured.",
      "secret": false,
      "strategy": "default",
      "default": "false"
    },
    {
      "key": "LLM_API_KEY",
      "service": "app",
      "description": "API key for LLM_PROVIDER. Can also be set later in the WebUI.",
      "secret": true,
      "strategy": "generate",
      "generate": "random_base64_32"
    },
    {
      "key": "SEED_SCRIPT",
      "service": "app",
      "description": "Internal: writes config.toml from these variables on first boot only. Do not edit.",
      "secret": false,
      "strategy": "default",
      "default": "\"\"\"Seed /data/config.toml from config.example.toml plus Railway variables.\n\nRuns once, on first boot only. After that the file on the volume is the source\nof truth, because the WebUI writes settings back to it and those edits must\nsurvive a redeploy.\n\"\"\"\n\nimport os\n\nimport toml\n\nSRC = \"/MoneyPrinterTurbo/config.example.toml\"\nDST = \"/data/config.toml\"\n\n\ndef env(name):\n    value = os.getenv(name, \"\").strip()\n    return value or None\n\n\ndef env_list(name):\n    value = env(name)\n    if not value:\n        return None\n    return [item.strip() for item in value.split(\",\") if item.strip()]\n\n\ncfg = toml.load(SRC)\napp = cfg.setdefault(\"app\", {})\n\nprovider = env(\"LLM_PROVIDER\") or app.get(\"llm_provider\", \"moonshot\")\napp[\"llm_provider\"] = provider\n\n# Each provider keeps its credentials under its own prefixed keys, so the\n# generic LLM_* variables are written to whichever provider is selected.\nfor var, suffix in (\n    (\"LLM_API_KEY\", \"api_key\"),\n    (\"LLM_BASE_URL\", \"base_url\"),\n    (\"LLM_MODEL_NAME\", \"model_name\"),\n):\n    value = env(var)\n    if value:\n        app[f\"{provider}_{suffix}\"] = value\n\nfor source in (\"pexels\", \"pixabay\", \"coverr\"):\n    keys = env_list(f\"{source.upper()}_API_KEYS\")\n    if keys:\n        app[f\"{source}_api_keys\"] = keys\n\nfor var, key in ((\"VIDEO_SOURCE\", \"video_source\"), (\"SUBTITLE_PROVIDER\", \"subtitle_provider\")):\n    value = env(var)\n    if value:\n        app[key] = value\n\nhide_config = env(\"HIDE_CONFIG\")\nif hide_config:\n    app[\"hide_config\"] = hide_config.lower() in (\"1\", \"true\", \"yes\")\n\nwhisper_model = env(\"WHISPER_MODEL_SIZE\")\nif whisper_model:\n    cfg.setdefault(\"whisper\", {})[\"model_size\"] = whisper_model\n\nwith open(DST, \"w\", encoding=\"utf-8\") as fp:\n    toml.dump(cfg, fp)\n\nprint(f\"seeded {DST} (llm_provider={provider})\")\n"
    },
    {
      "key": "LLM_BASE_URL",
      "service": "app",
      "description": "Override the provider's API base URL. Leave empty for its default.",
      "secret": false,
      "strategy": "ask_user"
    },
    {
      "key": "LLM_PROVIDER",
      "service": "app",
      "description": "LLM used to write scripts, e.g. moonshot, openai, gemini, deepseek, qwen.",
      "secret": false,
      "strategy": "default",
      "default": "moonshot"
    },
    {
      "key": "START_SCRIPT",
      "service": "app",
      "description": "Internal: boot script that wires the volume and runs both processes. Do not edit.",
      "secret": false,
      "strategy": "default",
      "default": "set -eu\n\n# The volume is the only writable, persistent path. config.toml and storage/\n# both live there; the app's own directory is recreated from the image on\n# every deploy.\nmkdir -p /data/storage\n\nif [ ! -f /data/config.toml ]; then\n\tprintf '%s' \"$SEED_SCRIPT\" > /tmp/seed-config.py\n\tpython3 /tmp/seed-config.py\nfi\n\n# config.toml must sit inside the app root: config.py derives its path from the\n# package location and rewrites the file in place via os.replace(), which would\n# clobber a symlink. So copy it in at boot and mirror edits back to the volume.\ncp -f /data/config.toml /MoneyPrinterTurbo/config.toml\n(\n\twhile true; do\n\t\tsleep 10\n\t\tcp -f /MoneyPrinterTurbo/config.toml /data/config.toml 2>/dev/null || true\n\tdone\n) &\n\n# storage/ only ever gets new files written into it, so a symlink is safe here.\nrm -rf /MoneyPrinterTurbo/storage\nln -sfn /data/storage /MoneyPrinterTurbo/storage\n\n# Upstream runs these as two containers sharing one bind mount. A Railway volume\n# attaches to a single service, and both processes need the same config.toml and\n# storage/, so they run side by side here instead.\npython3 main.py &\n\nexec streamlit run ./webui/Main.py \\\n\t--server.address=0.0.0.0 \\\n\t--server.port=8501 \\\n\t--browser.serverAddress=\"$PUBLIC_DOMAIN\" \\\n\t--browser.serverPort=443 \\\n\t--server.enableCORS=true \\\n\t--server.enableXsrfProtection=true \\\n\t--browser.gatherUsageStats=false \\\n\t--client.toolbarMode=minimal \\\n\t--logger.hideWelcomeMessage=true \\\n\t--server.showEmailPrompt=False\n"
    },
    {
      "key": "VIDEO_SOURCE",
      "service": "app",
      "description": "Where footage comes from: pexels, pixabay, coverr, or local.Can also be set later in the WebUI.",
      "secret": false,
      "strategy": "default",
      "default": "pexels"
    },
    {
      "key": "PUBLIC_DOMAIN",
      "service": "app",
      "description": "Proxy's public domain; Streamlit needs it to accept the websocket.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_domain"
    },
    {
      "key": "LLM_MODEL_NAME",
      "service": "app",
      "description": "Override the provider's model. Leave empty for its default.Can also be set later in the WebUI.",
      "secret": false,
      "strategy": "ask_user"
    },
    {
      "key": "COVERR_API_KEYS",
      "service": "app",
      "description": "Coverr keys, comma-separated. Needed if VIDEO_SOURCE=coverr.Can also be set later in the WebUI.",
      "secret": true,
      "strategy": "generate",
      "generate": "random_base64_32"
    },
    {
      "key": "PEXELS_API_KEYS",
      "service": "app",
      "description": "Free Pexels keys for stock footage, comma-separated. Needed if VIDEO_SOURCE=pexels.Can also be set later in the WebUI.",
      "secret": true,
      "strategy": "generate",
      "generate": "random_base64_32"
    },
    {
      "key": "PIXABAY_API_KEYS",
      "service": "app",
      "description": "Pixabay keys, comma-separated. Needed if VIDEO_SOURCE=pixabay.Can also be set later in the WebUI.",
      "secret": true,
      "strategy": "generate",
      "generate": "random_base64_32"
    },
    {
      "key": "SUBTITLE_PROVIDER",
      "service": "app",
      "description": "Subtitles from TTS timings (edge) or local transcription (whisper).",
      "secret": false,
      "strategy": "default",
      "default": "edge"
    },
    {
      "key": "WHISPER_MODEL_SIZE",
      "service": "app",
      "description": "Whisper model, downloaded on first use. Only used when SUBTITLE_PROVIDER=whisper.",
      "secret": false,
      "strategy": "default",
      "default": "large-v3-turbo"
    },
    {
      "key": "PORT",
      "service": "proxy",
      "description": "Port the proxy binds. Must match the public domain's port.",
      "secret": false,
      "strategy": "default",
      "default": "8080"
    },
    {
      "key": "API_HOST",
      "service": "proxy",
      "description": "Internal: private address of the FastAPI service.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "CADDYFILE",
      "service": "proxy",
      "description": "Internal: proxy config routing the API and WebUI. Do not edit.",
      "secret": false,
      "strategy": "default",
      "default": "{\n\tadmin off\n\tauto_https off\n}\n\n:{$PORT} {\n\trespond /proxy-healthz 200\n\n\t# The healthcheck must stay reachable without credentials, or the deploy\n\t# never goes healthy.\n\t@protected not path /proxy-healthz\n\tbasic_auth @protected {\n\t\t{$WEBUI_USERNAME} {$WEBUI_PASSWORD_HASH}\n\t}\n\n\t# The FastAPI service owns these paths; Streamlit serves everything else.\n\t@api path /api/* /docs /docs/* /redoc /openapi.json /tasks/*\n\treverse_proxy @api {$API_HOST}\n\n\treverse_proxy {$WEBUI_HOST}\n}\n"
    },
    {
      "key": "WEBUI_HOST",
      "service": "proxy",
      "description": "Internal: private address of the Streamlit WebUI.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "WEBUI_PASSWORD",
      "service": "proxy",
      "description": "Password for the login prompt.",
      "secret": true,
      "strategy": "generate",
      "generate": "strong_password"
    },
    {
      "key": "WEBUI_USERNAME",
      "service": "proxy",
      "description": "Username for the login prompt guarding the app.",
      "secret": false,
      "strategy": "ask_user"
    }
  ],
  "deploy": {
    "mcp": {
      "server": "railway",
      "tool": "deploy_template",
      "args": {
        "template_code": "money-printer-turbo"
      }
    },
    "cli": "railway deploy --template money-printer-turbo",
    "api": {
      "method": "POST",
      "path": "/graphql/v2",
      "body": {
        "query": "mutation templateDeploy($input: TemplateDeployV2Input!) { templateDeployV2(input: $input) { projectId workflowId } }",
        "variables": {
          "input": {
            "templateId": "1a296e7f-c711-43b7-8f68-d66fc940bf9c",
            "serializedConfig": {
              "services": {
                "40b6d26c-6e0f-493d-8497-18c0955631ac": {
                  "name": "app",
                  "deploy": {
                    "startCommand": "sh -c 'printf \"%s\" \"$START_SCRIPT\" > /tmp/start-app.sh && exec sh /tmp/start-app.sh'",
                    "healthcheckPath": "/_stcore/health"
                  },
                  "source": {
                    "image": "ghcr.io/harry0703/moneyprinterturbo:latest"
                  },
                  "variables": {
                    "PORT": {
                      "description": "Port Streamlit binds. Railway's healthcheck probes it, so leave at 8501.",
                      "defaultValue": "8501"
                    },
                    "HIDE_CONFIG": {
                      "description": "Hide the WebUI's settings panel once configured.",
                      "defaultValue": "false"
                    },
                    "LLM_API_KEY": {
                      "description": "API key for LLM_PROVIDER. Can also be set later in the WebUI.",
                      "defaultValue": "{{LLM_API_KEY}}"
                    },
                    "SEED_SCRIPT": {
                      "description": "Internal: writes config.toml from these variables on first boot only. Do not edit.",
                      "defaultValue": "\"\"\"Seed /data/config.toml from config.example.toml plus Railway variables.\n\nRuns once, on first boot only. After that the file on the volume is the source\nof truth, because the WebUI writes settings back to it and those edits must\nsurvive a redeploy.\n\"\"\"\n\nimport os\n\nimport toml\n\nSRC = \"/MoneyPrinterTurbo/config.example.toml\"\nDST = \"/data/config.toml\"\n\n\ndef env(name):\n    value = os.getenv(name, \"\").strip()\n    return value or None\n\n\ndef env_list(name):\n    value = env(name)\n    if not value:\n        return None\n    return [item.strip() for item in value.split(\",\") if item.strip()]\n\n\ncfg = toml.load(SRC)\napp = cfg.setdefault(\"app\", {})\n\nprovider = env(\"LLM_PROVIDER\") or app.get(\"llm_provider\", \"moonshot\")\napp[\"llm_provider\"] = provider\n\n# Each provider keeps its credentials under its own prefixed keys, so the\n# generic LLM_* variables are written to whichever provider is selected.\nfor var, suffix in (\n    (\"LLM_API_KEY\", \"api_key\"),\n    (\"LLM_BASE_URL\", \"base_url\"),\n    (\"LLM_MODEL_NAME\", \"model_name\"),\n):\n    value = env(var)\n    if value:\n        app[f\"{provider}_{suffix}\"] = value\n\nfor source in (\"pexels\", \"pixabay\", \"coverr\"):\n    keys = env_list(f\"{source.upper()}_API_KEYS\")\n    if keys:\n        app[f\"{source}_api_keys\"] = keys\n\nfor var, key in ((\"VIDEO_SOURCE\", \"video_source\"), (\"SUBTITLE_PROVIDER\", \"subtitle_provider\")):\n    value = env(var)\n    if value:\n        app[key] = value\n\nhide_config = env(\"HIDE_CONFIG\")\nif hide_config:\n    app[\"hide_config\"] = hide_config.lower() in (\"1\", \"true\", \"yes\")\n\nwhisper_model = env(\"WHISPER_MODEL_SIZE\")\nif whisper_model:\n    cfg.setdefault(\"whisper\", {})[\"model_size\"] = whisper_model\n\nwith open(DST, \"w\", encoding=\"utf-8\") as fp:\n    toml.dump(cfg, fp)\n\nprint(f\"seeded {DST} (llm_provider={provider})\")\n"
                    },
                    "LLM_BASE_URL": {
                      "description": "Override the provider's API base URL. Leave empty for its default.",
                      "defaultValue": "{{LLM_BASE_URL}}"
                    },
                    "LLM_PROVIDER": {
                      "description": "LLM used to write scripts, e.g. moonshot, openai, gemini, deepseek, qwen.",
                      "defaultValue": "moonshot"
                    },
                    "START_SCRIPT": {
                      "description": "Internal: boot script that wires the volume and runs both processes. Do not edit.",
                      "defaultValue": "set -eu\n\n# The volume is the only writable, persistent path. config.toml and storage/\n# both live there; the app's own directory is recreated from the image on\n# every deploy.\nmkdir -p /data/storage\n\nif [ ! -f /data/config.toml ]; then\n\tprintf '%s' \"$SEED_SCRIPT\" > /tmp/seed-config.py\n\tpython3 /tmp/seed-config.py\nfi\n\n# config.toml must sit inside the app root: config.py derives its path from the\n# package location and rewrites the file in place via os.replace(), which would\n# clobber a symlink. So copy it in at boot and mirror edits back to the volume.\ncp -f /data/config.toml /MoneyPrinterTurbo/config.toml\n(\n\twhile true; do\n\t\tsleep 10\n\t\tcp -f /MoneyPrinterTurbo/config.toml /data/config.toml 2>/dev/null || true\n\tdone\n) &\n\n# storage/ only ever gets new files written into it, so a symlink is safe here.\nrm -rf /MoneyPrinterTurbo/storage\nln -sfn /data/storage /MoneyPrinterTurbo/storage\n\n# Upstream runs these as two containers sharing one bind mount. A Railway volume\n# attaches to a single service, and both processes need the same config.toml and\n# storage/, so they run side by side here instead.\npython3 main.py &\n\nexec streamlit run ./webui/Main.py \\\n\t--server.address=0.0.0.0 \\\n\t--server.port=8501 \\\n\t--browser.serverAddress=\"$PUBLIC_DOMAIN\" \\\n\t--browser.serverPort=443 \\\n\t--server.enableCORS=true \\\n\t--server.enableXsrfProtection=true \\\n\t--browser.gatherUsageStats=false \\\n\t--client.toolbarMode=minimal \\\n\t--logger.hideWelcomeMessage=true \\\n\t--server.showEmailPrompt=False\n"
                    },
                    "VIDEO_SOURCE": {
                      "description": "Where footage comes from: pexels, pixabay, coverr, or local.Can also be set later in the WebUI.",
                      "defaultValue": "pexels"
                    },
                    "PUBLIC_DOMAIN": {
                      "description": "Proxy's public domain; Streamlit needs it to accept the websocket.",
                      "defaultValue": "${{proxy.RAILWAY_PUBLIC_DOMAIN}}"
                    },
                    "LLM_MODEL_NAME": {
                      "description": "Override the provider's model. Leave empty for its default.Can also be set later in the WebUI.",
                      "defaultValue": "{{LLM_MODEL_NAME}}"
                    },
                    "COVERR_API_KEYS": {
                      "description": "Coverr keys, comma-separated. Needed if VIDEO_SOURCE=coverr.Can also be set later in the WebUI.",
                      "defaultValue": "{{COVERR_API_KEYS}}"
                    },
                    "PEXELS_API_KEYS": {
                      "description": "Free Pexels keys for stock footage, comma-separated. Needed if VIDEO_SOURCE=pexels.Can also be set later in the WebUI.",
                      "defaultValue": "{{PEXELS_API_KEYS}}"
                    },
                    "PIXABAY_API_KEYS": {
                      "description": "Pixabay keys, comma-separated. Needed if VIDEO_SOURCE=pixabay.Can also be set later in the WebUI.",
                      "defaultValue": "{{PIXABAY_API_KEYS}}"
                    },
                    "SUBTITLE_PROVIDER": {
                      "description": "Subtitles from TTS timings (edge) or local transcription (whisper).",
                      "defaultValue": "edge"
                    },
                    "WHISPER_MODEL_SIZE": {
                      "description": "Whisper model, downloaded on first use. Only used when SUBTITLE_PROVIDER=whisper.",
                      "defaultValue": "large-v3-turbo"
                    }
                  },
                  "networking": {
                    "serviceDomains": {
                      "<hasDomain>:8501": {
                        "port": 8501
                      }
                    }
                  },
                  "volumeMounts": {
                    "40b6d26c-6e0f-493d-8497-18c0955631ac": {
                      "mountPath": "/data"
                    }
                  }
                },
                "c56a0315-0a89-4406-9984-ed52602e4090": {
                  "name": "proxy",
                  "deploy": {
                    "startCommand": "sh -c 'export WEBUI_PASSWORD_HASH=$(caddy hash-password --plaintext \"$WEBUI_PASSWORD\") && printf \"%s\" \"$CADDYFILE\" > /etc/caddy/Caddyfile && exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile'",
                    "healthcheckPath": "/proxy-healthz"
                  },
                  "source": {
                    "image": "caddy:2-alpine"
                  },
                  "variables": {
                    "PORT": {
                      "description": "Port the proxy binds. Must match the public domain's port.",
                      "defaultValue": "8080"
                    },
                    "API_HOST": {
                      "description": "Internal: private address of the FastAPI service.",
                      "defaultValue": "${{app.RAILWAY_PRIVATE_DOMAIN}}:8080"
                    },
                    "CADDYFILE": {
                      "description": "Internal: proxy config routing the API and WebUI. Do not edit.",
                      "defaultValue": "{\n\tadmin off\n\tauto_https off\n}\n\n:{$PORT} {\n\trespond /proxy-healthz 200\n\n\t# The healthcheck must stay reachable without credentials, or the deploy\n\t# never goes healthy.\n\t@protected not path /proxy-healthz\n\tbasic_auth @protected {\n\t\t{$WEBUI_USERNAME} {$WEBUI_PASSWORD_HASH}\n\t}\n\n\t# The FastAPI service owns these paths; Streamlit serves everything else.\n\t@api path /api/* /docs /docs/* /redoc /openapi.json /tasks/*\n\treverse_proxy @api {$API_HOST}\n\n\treverse_proxy {$WEBUI_HOST}\n}\n"
                    },
                    "WEBUI_HOST": {
                      "description": "Internal: private address of the Streamlit WebUI.",
                      "defaultValue": "${{app.RAILWAY_PRIVATE_DOMAIN}}:8501"
                    },
                    "WEBUI_PASSWORD": {
                      "description": "Password for the login prompt.",
                      "defaultValue": "{{WEBUI_PASSWORD}}"
                    },
                    "WEBUI_USERNAME": {
                      "description": "Username for the login prompt guarding the app.",
                      "defaultValue": "{{WEBUI_USERNAME}}"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "post_deploy": {
    "healthcheck": {
      "service": "app",
      "method": "GET",
      "path": "/_stcore/health",
      "expect_status": 200
    }
  },
  "resources": {
    "expected_services": 2,
    "needs_volume": true
  },
  "generated_at": "2026-09-10T16:14:52.212Z",
  "generator_version": "0.1.0"
}
