<!--
Sitemap:
- [Installation](/installation)
- [Upgrading](/upgrading): Version-specific steps for upgrading an existing Bento install.
- [Concepts](/concepts)
- [Build your first pipeline](/tutorials/pipeline-args)
- [Target a specific issue or PR from a URL](/tutorials/url-targeting)
- [Keep state across runs](/tutorials/pipeline-state)
- [Fire a pipeline on a schedule or on demand](/tutorials/schedule-and-fire)
- [Deploy a box to Railway](/tutorials/deploy-to-railway)
- [Operate a hosted daemon](/tutorials/operate-a-hosted-daemon)
- [Configuration](/configuration)
- [Members](/members)
- [Knowledge base](/knowledge-base/)
- [Method and delivery](/knowledge-base/modes)
- [Config](/knowledge-base/config)
- [MCP](/knowledge-base/mcp)
- [Pipeline configuration reference](/pipelines/config)
- [Filters](/pipelines/filters)
- [Triggers](/triggers/)
- [GitHub trigger](/triggers/github)
- [Linear trigger](/triggers/linear)
- [Webhook trigger](/triggers/webhook)
- [Schedule trigger](/triggers/schedule)
- [Manual trigger](/triggers/manual)
- [Traces](/pipelines/traces)
- [Slack](/integrations/slack)
- [Public access](/public-access)
- [Context engineering](/context-engineering)
- [Best practices](/best-practices)
- [Troubleshooting](/troubleshooting)
- [Architecture](/architecture/vision)
- [Workspaces](/workspaces)
- [Authentication](/authentication)
- [Identity](/identity)
- [Security](/security)
- [References](/references)
- [Changelog](/changelog): Bento release history.
- [CLI reference](/cli/)
- [Setup](/cli/setup)
- [Secrets](/cli/secrets)
- [Lifecycle](/cli/lifecycle)
- [Sandbox image](/cli/image)
- [Sandboxes](/cli/sandbox)
- [Observability](/cli/observability)
- [Diagnostics](/cli/diagnostics)
- [Triggers](/cli/triggers)
- [Workbench](/cli/workbench)
- [Auth](/cli/auth)
- [Knowledge](/cli/knowledge)
- [Evals](/cli/evals)
- [Bento](/index)
- [Runtime wrapper](/architecture/runtime-wrapper)
- [Skill evolve](/architecture/skill-evolve)
-->

# Deploy a box to Railway

This tutorial puts a bento box on Railway, with Postgres on Railway and agent sandboxes on Daytona. A box is a repository that holds agents, skills, policies, pipelines, and the daemon config. At the end, the daemon answers on a public domain. A GitHub webhook and a Linear webhook reach it, and a pipeline runs in a Daytona sandbox.

Railway cannot start a container inside a service. The daemon runs there, but no `docker` or `podman` is available to it, so every agent run goes to Daytona. Read [Configuration](/configuration) for the `sandboxes` block and [Setup](/cli/setup) for the `bento setup` commands this tutorial uses.

## 1. Write the image

The daemon needs the `bento` binary, `git`, `gh`, and the Claude Code CLI on its `PATH`. It resolves each runtime that a target names at boot, and it does not start when one is absent. A bun base image is sufficient. `bento` is a bun-compiled binary and Claude Code installs as a native binary, so the image needs no node.

```dockerfile
FROM oven/bun:1-debian

ARG BENTO_VERSION=0.11.1
ARG CLAUDE_CODE_VERSION=2.1.280

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates curl git \
    && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
      -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
    && echo "deb [signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
      > /etc/apt/sources.list.d/github-cli.list \
    && apt-get update \
    && apt-get install -y --no-install-recommends gh \
    && rm -rf /var/lib/apt/lists/*

RUN useradd --create-home --shell /bin/bash agent
USER agent
ENV HOME=/home/agent
ENV PATH="/home/agent/.local/bin:${PATH}"

RUN curl -fsSL https://claude.ai/install.sh | bash -s "${CLAUDE_CODE_VERSION}"
RUN mkdir -p /home/agent/.local/bin \
    && curl -fsSL "https://install.getbento.sh/releases/${BENTO_VERSION}/bento-linux-x64" \
      -o /home/agent/.local/bin/bento \
    && chmod +x /home/agent/.local/bin/bento

WORKDIR /home/agent/box
COPY --chown=agent:agent agents/ agents/
COPY --chown=agent:agent skills/ skills/
COPY --chown=agent:agent policies/ policies/
COPY --chown=agent:agent .bento/pipelines/ .bento/pipelines/
COPY --chown=agent:agent .bento/members.yaml .bento/groups.yaml .bento/
COPY --chown=agent:agent deploy/railway/daemon.yaml .bento/daemon.yaml
COPY --chown=agent:agent deploy/railway/entrypoint.sh /home/agent/entrypoint.sh

EXPOSE 7890
CMD ["/home/agent/entrypoint.sh"]
```

Keep a `wiki/` directory out of the image. The daemon indexes a wiki with `qmd` at boot, and it stops when that binary is absent.

Railway's builder rejects a `RUN --mount=type=cache` line without an `id`. Use plain `apt-get` with a cleanup step.

## 2. Write the hosted daemon config

Keep the hosted config in `deploy/railway/daemon.yaml`, next to the local `.bento/daemon.yaml`. The image copies the hosted file over the local one. Start with the complete local config. Keep its GitHub, Linear, target, and other daemon settings. Apply these Railway changes to the complete file.

```yaml
database:
  url: ${DATABASE_URL}

port: ${PORT}

webhooks:
  host: 0.0.0.0
  sources:
    github:
      verify: github
      secret: ${GITHUB_WEBHOOK_SECRET}
    linear:
      verify: linear
      secret: ${LINEAR_WEBHOOK_SECRET}

sandboxes:
  backend: daytona
  daytona:
    snapshot: my-box-agent
```

The daemon reads `database.url` only. It does not read the `DATABASE_URL` variable on its own. It does not read `PORT` on its own either, so `port: ${PORT}` is what makes Railway's proxy reach it. The default bind address is loopback, so the hosted config sets `webhooks.host: 0.0.0.0`.

A `${VAR}` reference expands everywhere in the file, in a comment too. An unset variable expands to an empty string. Write a variable name in words inside a comment.

## 3. Write the entrypoint

Two things the daemon expects on disk do not come from a variable. The GitHub principal resolves through the profile that `bento setup gh` writes. The credentials file holds the daemon bearer token. Linear application credentials live in Postgres.

```sh
#!/bin/sh
set -eu

if [ -n "${GH_TOKEN:-}" ]; then
  bento setup gh --env GH_TOKEN
fi

credentials="$HOME/.bento/credentials"
if [ -n "${BENTO_CREDENTIALS_JSON:-}" ] && [ ! -s "$credentials" ]; then
  mkdir -p "$HOME/.bento"
  umask 077
  printf '%s' "$BENTO_CREDENTIALS_JSON" > "$credentials"
fi

exec bento serve
```

On a workstation, create the daemon token in a scratch `HOME` directory:

```sh
scratch=$(mktemp -d)
HOME="$scratch" bento auth setup
cat "$scratch/.bento/credentials"
```

Copy the complete file into the `BENTO_CREDENTIALS_JSON` variable. Copy its `token` value into `~/.bento/credentials` on the workstation before you use the remote CLI.

Set up Linear separately after the Railway database is available. From the project directory, run `bento setup linear` with `database.url` connected to that database and the hosted daemon's `name`. Supply `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, and the [encryption key](/cli/setup#encrypted-credentials) to the setup process. Inject the same encryption key and version into Railway. Remove the `linear` YAML block and client setup variables after setup. No Linear credential file needs copying.

## 4. Declare the Railway project

Railway reads the project from `.railway/railway.ts`. Install the package with `bun add -d railway`, then declare the service, the database, and the region.

```ts
import { defineRailway, github, postgres, preserve, project, service } from "railway/iac";

const REGION = "europe-west4-drams3a";

export default defineRailway(() => {
  const db = postgres("Postgres", { region: REGION });

  const daemon = service("daemon", {
    source: github("my-org/my-box", { branch: "main" }),
    build: { builder: "DOCKERFILE", dockerfilePath: "Dockerfile" },
    start: "/home/agent/entrypoint.sh",
    healthcheck: "/health",
    healthcheckTimeout: 300,
    replicas: { [REGION]: 1 },
    env: {
      DATABASE_URL: db.env.DATABASE_URL,
      RAILWAY_RUN_UID: "0",
      BENTO_CREDENTIALS_JSON: preserve(),
      CLAUDE_CODE_OAUTH_TOKEN: preserve(),
      DAYTONA_API_KEY: preserve(),
      GH_TOKEN: preserve(),
      GITHUB_WEBHOOK_SECRET: preserve(),
      BENTO_CREDENTIAL_ENCRYPTION_KEY: preserve(),
      BENTO_CREDENTIAL_ENCRYPTION_KEY_VERSION: preserve(),
      LINEAR_WEBHOOK_SECRET: preserve(),
    },
  });

  return project("my-box", { resources: [daemon, db] });
});
```

Run `railway config plan` to see the changes, then `railway config apply`. Set each secret with `railway variables --service daemon --set NAME=value`. A `preserve()` entry keeps a value that you set this way.

A volume mounts as root. Set `RAILWAY_RUN_UID=0` on the service, which is Railway's documented setting for an image with a non-root user. A volume is also pinned to a region, and a deleted volume stays attached for 48 hours. Create the volume in the region the service uses.

Railway refuses a custom domain in the authoring file. Create one with `railway domain <name> --service daemon`, and add the CNAME and TXT records it prints at your DNS provider.

## 5. Build the sandbox snapshot

A Daytona sandbox has no filesystem in common with the daemon, so the daemon cannot mount skills into it. Build a snapshot from an image that carries the skills at the path the daemon uses for a mount. Put the members file where the Linear skill reads it.

```dockerfile
FROM ghcr.io/1a35e1/bento/agent:default
COPY --chown=agent:agent skills/ /home/agent/.claude/skills/
COPY --chown=agent:agent .bento/members.yaml /home/agent/.claude/.bento/members.yaml
```

Build the snapshot with the Daytona SDK from a workstation, and give it a size. The default size is 1 vCPU and 1 GiB, which is too small for a package install and a build.

```ts
import { Daytona, Image } from '@daytona/sdk'

const daytona = new Daytona()
const image = Image.fromDockerfile('agent.Dockerfile').cmd(['sleep', 'infinity'])
await daytona.snapshot.create(
  { name: 'my-box-agent', image, resources: { cpu: 2, memory: 4, disk: 10 } },
  { onLogs: (chunk) => process.stdout.write(chunk) },
)
```

A pipeline that names `companions:` needs a mount, so remove that field and point the instructions at `~/.claude/skills/<name>/...` instead. Build the snapshot again after a change to `skills/` or `members.yaml`.

Daytona limits the memory of all sandboxes that run at the same time. Set `queue.remote.concurrency` in the daemon config to the number of snapshots that fit that limit.

## 6. Connect the webhooks

Point the GitHub webhook of each repository at `https://<domain>/events`, with the `pull_request` event and the `GITHUB_WEBHOOK_SECRET` value. Point the Linear application's webhook at `https://<domain>/webhooks/linear`, with agent session events on, and copy its signing secret into `LINEAR_WEBHOOK_SECRET`. Read [Linear triggers](/triggers/linear) for the events a pipeline can match.

## 7. Verify

Push to the tracked branch, or run `railway up` from the repository to deploy the working tree. Then check these three things.

1. `curl https://<domain>/health` returns `{"status":"ok",...}`.
2. `railway logs` shows `runtime resolved: claude`, `sandbox: selected daytona`, and one `principal ... resolved` line for GitHub and one for Linear.
3. A run completes. Fire a configured pipeline with `bento trigger fire <pipeline>`. Add the required arguments and target flags for the pipeline. Read [Operate a hosted daemon](/tutorials/operate-a-hosted-daemon) for how to reach the daemon and follow the run.
