{
  "manifest_version": "1.0.0",
  "template": {
    "id": "acf952fd-c322-43c5-aeca-a3d80efba557",
    "slug": "temporal-workflow",
    "name": "Temporal | Open Source Durable Workflow Engine",
    "description": "Durable execution on Postgres — no Elasticsearch, console behind a password",
    "url": "https://railway.com/deploy/temporal-workflow",
    "upstream": {
      "image": "caddy:2.11.4-alpine"
    }
  },
  "services": [
    {
      "name": "Temporal UI",
      "source": {
        "image": "temporalio/ui:2.54.0"
      },
      "needs_volume": false,
      "http": false
    },
    {
      "name": "Auth Gateway",
      "source": {
        "image": "caddy:2.11.4-alpine"
      },
      "needs_volume": false,
      "http": true
    },
    {
      "name": "Temporal",
      "source": {
        "image": "temporalio/auto-setup:1.29.7"
      },
      "needs_volume": false,
      "http": false
    },
    {
      "name": "Postgres",
      "source": {
        "image": "postgres:18.6-alpine"
      },
      "needs_volume": true,
      "volume_mount_path": "/var/lib/postgresql",
      "http": false
    }
  ],
  "required_inputs": [
    {
      "key": "PORT",
      "service": "Temporal UI",
      "description": "Same port again — Railway routes and health-checks by this variable.",
      "secret": false,
      "strategy": "default",
      "default": "8080"
    },
    {
      "key": "TEMPORAL_ADDRESS",
      "service": "Temporal UI",
      "description": "Frontend the console talks to, over the private network.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "TEMPORAL_UI_PORT",
      "service": "Temporal UI",
      "description": "Port the console listens on.",
      "secret": false,
      "strategy": "default",
      "default": "8080"
    },
    {
      "key": "TEMPORAL_CORS_ORIGINS",
      "service": "Temporal UI",
      "description": "Origin the browser actually sends. It is the gateway's domain, not the console's own, because the console has no public domain of its own.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_domain"
    },
    {
      "key": "TEMPORAL_DEFAULT_NAMESPACE",
      "service": "Temporal UI",
      "description": "Namespace the console opens on.",
      "secret": false,
      "strategy": "default",
      "default": "default"
    },
    {
      "key": "TEMPORAL_DISABLE_NEWS_FETCH",
      "service": "Temporal UI",
      "description": "Stops the console calling out to temporal.io for release notes on every page load.",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "PORT",
      "service": "Auth Gateway",
      "description": "Port the gateway listens on and that the public domain points at.",
      "secret": false,
      "strategy": "default",
      "default": "8080"
    },
    {
      "key": "UPSTREAM",
      "service": "Auth Gateway",
      "description": "Private address of the console this gateway proxies to.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "CADDYFILE",
      "service": "Auth Gateway",
      "description": "Caddy configuration, written to disk at boot. `/healthz` is answered without a password so Railway's healthcheck can pass; everything else sits behind basic auth. Edit it to add IP allow-lists, rate limits or your own routes.",
      "secret": false,
      "strategy": "default",
      "default": ":{$PORT} {\n\thandle /healthz {\n\t\trespond \"ok\" 200\n\t}\n\thandle {\n\t\tbasic_auth {\n\t\t\t{$GATEWAY_USERNAME} {$CADDY_HASH}\n\t\t}\n\t\treverse_proxy {$UPSTREAM}\n\t}\n}\n"
    },
    {
      "key": "GATEWAY_PASSWORD",
      "service": "Auth Gateway",
      "description": "Password for that prompt, generated once. Change it here and redeploy to rotate — the bcrypt hash Caddy uses is derived from it at boot, so there is nothing else to update.",
      "secret": true,
      "strategy": "generate",
      "generate": "strong_password"
    },
    {
      "key": "GATEWAY_USERNAME",
      "service": "Auth Gateway",
      "description": "Username for the login prompt in front of the Temporal console.",
      "secret": false,
      "strategy": "default",
      "default": "admin"
    },
    {
      "key": "DB",
      "service": "Temporal",
      "description": "Name of Temporal's SQL plugin, not a server version. `postgres12` is the correct value for every PostgreSQL from 12 to 18; anything else aborts the boot with \"Unsupported driver\".",
      "secret": false,
      "strategy": "default",
      "default": "postgres12"
    },
    {
      "key": "PORT",
      "service": "Temporal",
      "description": "gRPC port the frontend listens on, for workers inside the project.",
      "secret": false,
      "strategy": "default",
      "default": "7233"
    },
    {
      "key": "DB_PORT",
      "service": "Temporal",
      "description": "Port of the Postgres service.",
      "secret": false,
      "strategy": "default",
      "default": "5432"
    },
    {
      "key": "ENABLE_ES",
      "service": "Temporal",
      "description": "Elasticsearch for the visibility store. Off: visibility lives in Postgres, which is what keeps this deployment to four small services instead of six large ones. Turning it on requires an Elasticsearch service and its own environment variables.",
      "secret": false,
      "strategy": "default",
      "default": "false"
    },
    {
      "key": "BIND_ON_IP",
      "service": "Temporal",
      "description": "Address every Temporal role binds to. Railway's private network is IPv6-only, so this has to be the wildcard `::` — with `0.0.0.0` nothing in the project can reach the frontend, including the console.",
      "secret": false,
      "strategy": "default",
      "default": "::"
    },
    {
      "key": "POSTGRES_PWD",
      "service": "Temporal",
      "description": "Password for that user, taken straight from the Postgres service.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "reference_variable"
    },
    {
      "key": "POSTGRES_USER",
      "service": "Temporal",
      "description": "User Temporal connects to Postgres as.",
      "secret": false,
      "strategy": "default",
      "default": "postgres"
    },
    {
      "key": "POSTGRES_SEEDS",
      "service": "Temporal",
      "description": "Private hostname of the Postgres service.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "TEMPORAL_ADDRESS",
      "service": "Temporal",
      "description": "Where this container's own CLI reaches the frontend while registering the default namespace on the first boot.",
      "secret": false,
      "strategy": "default",
      "default": "127.0.0.1:7233"
    },
    {
      "key": "DEFAULT_NAMESPACE",
      "service": "Temporal",
      "description": "Namespace registered on the first boot. Your workers and clients connect to it.",
      "secret": false,
      "strategy": "default",
      "default": "default"
    },
    {
      "key": "NUM_HISTORY_SHARDS",
      "service": "Temporal",
      "description": "Shard count. It is written into the database on the first boot and CANNOT be changed afterwards — a different value means a new cluster. 512 is Temporal's production recommendation; the 4 used by the upstream docker-compose is a development value that caps throughput permanently.",
      "secret": false,
      "strategy": "default",
      "default": "512"
    },
    {
      "key": "TEMPORAL_BROADCAST_ADDRESS",
      "service": "Temporal",
      "description": "Address the roles advertise to each other through ringpop membership. All four run in this one container, so loopback is both correct and stable — unlike the container's own IPv6 address, which changes on every deploy.",
      "secret": false,
      "strategy": "default",
      "default": "127.0.0.1"
    },
    {
      "key": "DEFAULT_NAMESPACE_RETENTION",
      "service": "Temporal",
      "description": "How long closed workflow histories stay queryable in that namespace before Temporal deletes them. Longer retention costs database volume.",
      "secret": false,
      "strategy": "default",
      "default": "72h"
    },
    {
      "key": "SKIP_DEFAULT_NAMESPACE_CREATION",
      "service": "Temporal",
      "description": "Set to true if you would rather create every namespace yourself.",
      "secret": false,
      "strategy": "default",
      "default": "false"
    },
    {
      "key": "PORT",
      "service": "Postgres",
      "description": "Port Railway routes to. Postgres listens on 5432.",
      "secret": false,
      "strategy": "default",
      "default": "5432"
    },
    {
      "key": "POSTGRES_DB",
      "service": "Postgres",
      "description": "Database the image creates for itself. Temporal does not store anything here — it gets its own two databases — but the name must differ from the user name, or auto-setup skips the CREATE and finds nothing.",
      "secret": false,
      "strategy": "default",
      "default": "railway"
    },
    {
      "key": "POSTGRES_USER",
      "service": "Postgres",
      "description": "Superuser Temporal connects as. It needs permission to create databases: Temporal's auto-setup creates `temporal` and `temporal_visibility` itself on the first boot.",
      "secret": false,
      "strategy": "default",
      "default": "postgres"
    },
    {
      "key": "POSTGRES_PASSWORD",
      "service": "Postgres",
      "description": "Generated once and read by Temporal over the private network. Nothing else uses it.",
      "secret": true,
      "strategy": "generate",
      "generate": "strong_password"
    }
  ],
  "deploy": {
    "mcp": {
      "server": "railway",
      "tool": "deploy_template",
      "args": {
        "template_code": "temporal-workflow"
      }
    },
    "cli": "railway deploy --template temporal-workflow",
    "api": {
      "method": "POST",
      "path": "/graphql/v2",
      "body": {
        "query": "mutation templateDeploy($input: TemplateDeployV2Input!) { templateDeployV2(input: $input) { projectId workflowId } }",
        "variables": {
          "input": {
            "templateId": "acf952fd-c322-43c5-aeca-a3d80efba557",
            "serializedConfig": {
              "buckets": {},
              "services": {
                "4870c50e-9a09-441f-b186-cf2ef73549bf": {
                  "icon": "https://temporal.io/favicon.svg",
                  "name": "Temporal UI",
                  "deploy": {
                    "startCommand": null,
                    "healthcheckPath": null,
                    "restartPolicyType": "ON_FAILURE",
                    "restartPolicyMaxRetries": 2
                  },
                  "source": {
                    "image": "temporalio/ui:2.54.0"
                  },
                  "variables": {
                    "PORT": {
                      "isOptional": false,
                      "description": "Same port again — Railway routes and health-checks by this variable.",
                      "defaultValue": "8080"
                    },
                    "TEMPORAL_ADDRESS": {
                      "isOptional": false,
                      "description": "Frontend the console talks to, over the private network.",
                      "defaultValue": "${{Temporal.RAILWAY_PRIVATE_DOMAIN}}:7233"
                    },
                    "TEMPORAL_UI_PORT": {
                      "isOptional": false,
                      "description": "Port the console listens on.",
                      "defaultValue": "8080"
                    },
                    "TEMPORAL_CORS_ORIGINS": {
                      "isOptional": false,
                      "description": "Origin the browser actually sends. It is the gateway's domain, not the console's own, because the console has no public domain of its own.",
                      "defaultValue": "https://${{Auth Gateway.RAILWAY_PUBLIC_DOMAIN}}"
                    },
                    "TEMPORAL_DEFAULT_NAMESPACE": {
                      "isOptional": false,
                      "description": "Namespace the console opens on.",
                      "defaultValue": "default"
                    },
                    "TEMPORAL_DISABLE_NEWS_FETCH": {
                      "isOptional": false,
                      "description": "Stops the console calling out to temporal.io for release notes on every page load.",
                      "defaultValue": "true"
                    }
                  }
                },
                "83a1ea9e-a6c1-4789-b41f-77c1a64198c9": {
                  "icon": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/caddy.svg",
                  "name": "Auth Gateway",
                  "deploy": {
                    "startCommand": "sh -c 'export CADDY_HASH=\"$(caddy hash-password --plaintext \"$GATEWAY_PASSWORD\")\"; printf \"%s\" \"$CADDYFILE\" > /etc/caddy/Caddyfile; exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile'",
                    "healthcheckPath": "/healthz",
                    "restartPolicyType": "ON_FAILURE",
                    "restartPolicyMaxRetries": 2
                  },
                  "source": {
                    "image": "caddy:2.11.4-alpine"
                  },
                  "variables": {
                    "PORT": {
                      "isOptional": false,
                      "description": "Port the gateway listens on and that the public domain points at.",
                      "defaultValue": "8080"
                    },
                    "UPSTREAM": {
                      "isOptional": false,
                      "description": "Private address of the console this gateway proxies to.",
                      "defaultValue": "http://${{Temporal UI.RAILWAY_PRIVATE_DOMAIN}}:8080"
                    },
                    "CADDYFILE": {
                      "isOptional": false,
                      "description": "Caddy configuration, written to disk at boot. `/healthz` is answered without a password so Railway's healthcheck can pass; everything else sits behind basic auth. Edit it to add IP allow-lists, rate limits or your own routes.",
                      "defaultValue": ":{$PORT} {\n\thandle /healthz {\n\t\trespond \"ok\" 200\n\t}\n\thandle {\n\t\tbasic_auth {\n\t\t\t{$GATEWAY_USERNAME} {$CADDY_HASH}\n\t\t}\n\t\treverse_proxy {$UPSTREAM}\n\t}\n}\n"
                    },
                    "GATEWAY_PASSWORD": {
                      "isOptional": false,
                      "description": "Password for that prompt, generated once. Change it here and redeploy to rotate — the bcrypt hash Caddy uses is derived from it at boot, so there is nothing else to update.",
                      "defaultValue": "{{GATEWAY_PASSWORD}}"
                    },
                    "GATEWAY_USERNAME": {
                      "isOptional": false,
                      "description": "Username for the login prompt in front of the Temporal console.",
                      "defaultValue": "admin"
                    }
                  },
                  "networking": {
                    "serviceDomains": {
                      "<hasDomain>:8080": {
                        "port": 8080
                      }
                    }
                  }
                },
                "c460231e-a983-499f-b257-17e6abcaf02e": {
                  "icon": "https://temporal.io/favicon.svg",
                  "name": "Temporal",
                  "deploy": {
                    "startCommand": null,
                    "healthcheckPath": null,
                    "restartPolicyType": "ON_FAILURE",
                    "restartPolicyMaxRetries": 2
                  },
                  "source": {
                    "image": "temporalio/auto-setup:1.29.7"
                  },
                  "variables": {
                    "DB": {
                      "isOptional": false,
                      "description": "Name of Temporal's SQL plugin, not a server version. `postgres12` is the correct value for every PostgreSQL from 12 to 18; anything else aborts the boot with \"Unsupported driver\".",
                      "defaultValue": "postgres12"
                    },
                    "PORT": {
                      "isOptional": false,
                      "description": "gRPC port the frontend listens on, for workers inside the project.",
                      "defaultValue": "7233"
                    },
                    "DB_PORT": {
                      "isOptional": false,
                      "description": "Port of the Postgres service.",
                      "defaultValue": "5432"
                    },
                    "ENABLE_ES": {
                      "isOptional": false,
                      "description": "Elasticsearch for the visibility store. Off: visibility lives in Postgres, which is what keeps this deployment to four small services instead of six large ones. Turning it on requires an Elasticsearch service and its own environment variables.",
                      "defaultValue": "false"
                    },
                    "BIND_ON_IP": {
                      "isOptional": false,
                      "description": "Address every Temporal role binds to. Railway's private network is IPv6-only, so this has to be the wildcard `::` — with `0.0.0.0` nothing in the project can reach the frontend, including the console.",
                      "defaultValue": "::"
                    },
                    "POSTGRES_PWD": {
                      "isOptional": false,
                      "description": "Password for that user, taken straight from the Postgres service.",
                      "defaultValue": "${{Postgres.POSTGRES_PASSWORD}}"
                    },
                    "POSTGRES_USER": {
                      "isOptional": false,
                      "description": "User Temporal connects to Postgres as.",
                      "defaultValue": "postgres"
                    },
                    "POSTGRES_SEEDS": {
                      "isOptional": false,
                      "description": "Private hostname of the Postgres service.",
                      "defaultValue": "${{Postgres.RAILWAY_PRIVATE_DOMAIN}}"
                    },
                    "TEMPORAL_ADDRESS": {
                      "isOptional": false,
                      "description": "Where this container's own CLI reaches the frontend while registering the default namespace on the first boot.",
                      "defaultValue": "127.0.0.1:7233"
                    },
                    "DEFAULT_NAMESPACE": {
                      "isOptional": false,
                      "description": "Namespace registered on the first boot. Your workers and clients connect to it.",
                      "defaultValue": "default"
                    },
                    "NUM_HISTORY_SHARDS": {
                      "isOptional": false,
                      "description": "Shard count. It is written into the database on the first boot and CANNOT be changed afterwards — a different value means a new cluster. 512 is Temporal's production recommendation; the 4 used by the upstream docker-compose is a development value that caps throughput permanently.",
                      "defaultValue": "512"
                    },
                    "TEMPORAL_BROADCAST_ADDRESS": {
                      "isOptional": false,
                      "description": "Address the roles advertise to each other through ringpop membership. All four run in this one container, so loopback is both correct and stable — unlike the container's own IPv6 address, which changes on every deploy.",
                      "defaultValue": "127.0.0.1"
                    },
                    "DEFAULT_NAMESPACE_RETENTION": {
                      "isOptional": false,
                      "description": "How long closed workflow histories stay queryable in that namespace before Temporal deletes them. Longer retention costs database volume.",
                      "defaultValue": "72h"
                    },
                    "SKIP_DEFAULT_NAMESPACE_CREATION": {
                      "isOptional": false,
                      "description": "Set to true if you would rather create every namespace yourself.",
                      "defaultValue": "false"
                    }
                  }
                },
                "e9afe378-bbd6-46f3-ad3b-8e7f10312df6": {
                  "icon": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg",
                  "name": "Postgres",
                  "deploy": {
                    "startCommand": null,
                    "healthcheckPath": null,
                    "restartPolicyType": "ON_FAILURE",
                    "restartPolicyMaxRetries": 2
                  },
                  "source": {
                    "image": "postgres:18.6-alpine"
                  },
                  "variables": {
                    "PORT": {
                      "isOptional": false,
                      "description": "Port Railway routes to. Postgres listens on 5432.",
                      "defaultValue": "5432"
                    },
                    "POSTGRES_DB": {
                      "isOptional": false,
                      "description": "Database the image creates for itself. Temporal does not store anything here — it gets its own two databases — but the name must differ from the user name, or auto-setup skips the CREATE and finds nothing.",
                      "defaultValue": "railway"
                    },
                    "POSTGRES_USER": {
                      "isOptional": false,
                      "description": "Superuser Temporal connects as. It needs permission to create databases: Temporal's auto-setup creates `temporal` and `temporal_visibility` itself on the first boot.",
                      "defaultValue": "postgres"
                    },
                    "POSTGRES_PASSWORD": {
                      "isOptional": false,
                      "description": "Generated once and read by Temporal over the private network. Nothing else uses it.",
                      "defaultValue": "{{POSTGRES_PASSWORD}}"
                    }
                  },
                  "volumeMounts": {
                    "e9afe378-bbd6-46f3-ad3b-8e7f10312df6": {
                      "mountPath": "/var/lib/postgresql"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "post_deploy": {
    "healthcheck": {
      "service": "Auth Gateway",
      "method": "GET",
      "path": "/healthz",
      "expect_status": 200
    }
  },
  "resources": {
    "expected_services": 4,
    "needs_volume": true
  },
  "generated_at": "2026-09-19T16:14:39.735Z",
  "generator_version": "0.1.0",
  "status": "validated",
  "validated_at": "2026-09-19T19:47:13.702Z",
  "success_rate_30d": 1,
  "validation": {
    "last_run_id": "run_1b454739326647a9986c",
    "checks": [
      {
        "name": "workflow_completed",
        "passed": true
      },
      {
        "name": "all_services_deployed",
        "passed": true
      },
      {
        "name": "healthcheck",
        "passed": true
      },
      {
        "name": "stays_up",
        "passed": true
      }
    ],
    "typical_ready_seconds": 72,
    "typical_build_seconds": 0,
    "typical_start_seconds": 10,
    "slowest_service": "Temporal UI"
  }
}
