> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sevalla.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> These are the docs for Sevalla, a platform for deploying applications, databases, static sites, object storage and load balancers. The dashboard is at https://app.sevalla.com.
> Bold text in steps is an exact dashboard label.
> To act on a Sevalla account, prefer the Sevalla MCP server (https://mcp.sevalla.com/mcp, see https://docs.sevalla.com/ai-agents/overview.md), then the Sevalla CLI (https://docs.sevalla.com/developer-tools/cli.md), then the REST API (https://api-docs.sevalla.com, OpenAPI spec at https://api.sevalla.com/v3/openapi.json).
> Every page index is at https://docs.sevalla.com/llms.txt.

# Run Claude Code on Sevalla

> Run Claude Code in a tmux session on Sevalla that keeps working when you close the tab: a private application with a disk, opened from the web terminal, that pushes to GitHub.

Claude Code runs on Sevalla as an application whose web process has HTTP traffic turned off: a start script clones your repository to a disk and starts Claude Code in a tmux session, and the dashboard's **Web terminal** opens straight into that session. Close the tab and Claude Code keeps working; open the terminal again from any browser and you're back where you left it. The image is built from a Dockerfile, because it needs Claude Code, tmux, the GitHub CLI and Git, plus shell and Claude Code settings in system folders that Ingot doesn't write.

## At a glance

| | |
| - | - |
| Source | A Git repository with the `Dockerfile` and scripts from this guide |
| Build | The [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md): Node.js 24 on Debian 13, Claude Code 2.1.308 |
| Start command | The image's `CMD`, `/opt/claude/start.sh`, under `tini` |
| Port | None: [HTTP traffic is off](https://docs.sevalla.com/applications/networking.md#turn-http-traffic-off) |
| Processes | Web process, HTTP traffic off, 1 instance |
| Services | None |
| Disk | 10 GB at `/data`, holding the home folder and the clone |
| Health check | None |
| Size | **S1** |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `ANTHROPIC_API_KEY` | Yes, or `CLAUDE_CODE_OAUTH_TOKEN` | Runtime | Yes | A Claude API key from the [Claude Console](https://platform.claude.com/settings/keys) |
| `CLAUDE_CODE_OAUTH_TOKEN` | Instead of `ANTHROPIC_API_KEY` | Runtime | Yes | A token from `claude setup-token`, for a Claude Pro, Max, Team or Enterprise plan. See [Use a Claude subscription](#use-a-claude-subscription) |
| `GH_TOKEN` | Yes | Runtime | Yes | A fine-grained GitHub token for the repository. See [Create a GitHub token](#create-a-github-token) |
| `GITHUB_REPO` | Yes | Runtime | No | The repository Claude Code works on, as `owner/name`, for example `acme/storefront` |
| `ANTHROPIC_MODEL` | No | Runtime | No | The model new sessions start with, for example `claude-sonnet-5-5`. Without it, Claude Code's default: Opus 5.5 with an API key |

## How the session works

![You open the Web terminal in Sevalla's dashboard, which attaches to a tmux session in the application. Claude Code runs there, with its home folder, repository and conversations on a disk at /data, so they survive restarts and deployments, while a new session starts in place of the old one. It pushes to GitHub, and closing the tab leaves it running.](https://assets.sevalla.com/brand/diagrams/docs/remote-claude-code-light.svg)

* **One image, one long-lived process.** The web process runs `start.sh`, which clones the repository once, starts a tmux session named `claude` with Claude Code in it, and then waits. Nothing listens on a port, and with HTTP traffic off nothing on the internet can reach the application.
* **The web terminal joins the session.** The image sets `ENV` to a small script, which `sh` runs when it starts interactively, and adds the same script to `/etc/bash.bashrc` for `bash`. The script attaches to the `claude` session, so every shell the [web terminal](https://docs.sevalla.com/applications/web-terminal.md) opens lands in Claude Code.
* **The disk keeps the work.** `HOME` is `/data/home` and the clone is `/data/repo`, both on a [disk](https://docs.sevalla.com/applications/disks.md). Claude Code's settings, conversation history and your uncommitted changes survive restarts and deployments. The tmux session and a task that's running don't: see [What survives a restart or a deployment](#what-survives-a-restart-or-a-deployment).
* **GitHub is where work leaves the instance.** Claude Code commits and pushes with a GitHub token that can reach one repository, and opens pull requests that you review and merge.

## Before you begin

* A Sevalla company with a payment method. The **S1** size needs one. See [Pricing](https://docs.sevalla.com/billing/pricing.md).
* A GitHub repository for Claude Code to work on, and a second, private one for this guide's files, for example `acme/claude-workspace`.
* A Claude API key from the [Claude Console](https://platform.claude.com/settings/keys), with a spend limit on its workspace. Or a Claude Pro, Max, Team or Enterprise plan and Claude Code on your computer, to create a token with `claude setup-token`.
* Git on your computer.

## Create a GitHub token

Claude Code pushes branches and opens pull requests with a fine-grained personal access token that can reach only the repository it works on. Create one at [github.com/settings/personal-access-tokens/new](https://github.com/settings/personal-access-tokens/new):

* **Repository access**: only the repository Claude Code works on, for example `acme/storefront`.
* **Repository permissions**: **Contents** read and write, and **Pull requests** read and write. GitHub adds **Metadata** read-only by itself.
* **Expiration**: the shortest that suits you. When it expires, `start.sh` stops with `gh: Bad credentials (HTTP 401)` until you set a new token and deploy.

Commits and pull requests appear as the GitHub user who owns the token. To tell Claude Code's work apart from yours, create the token for a separate GitHub user that you invite to the repository as a collaborator.

## Create the project

The project is six small files. Create a folder for them:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
mkdir claude-workspace && cd claude-workspace
```

The Dockerfile installs Claude Code at a pinned version, with tmux, the GitHub CLI and Git from Debian's packages:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
FROM node:24-trixie-slim

# hadolint ignore=DL3008
RUN apt-get update \
  && apt-get install -y --no-install-recommends ca-certificates gh git jq less tini tmux \
  && rm -rf /var/lib/apt/lists/*

# Pinned, so a deployment never changes Claude Code under you. Update it on purpose.
ARG CLAUDE_CODE_VERSION=2.1.308
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
  && npm cache clean --force

COPY managed-settings.json /etc/claude-code/managed-settings.json
COPY tmux.conf /etc/tmux.conf
COPY start.sh shell-init.sh /opt/claude/
RUN echo '. /opt/claude/shell-init.sh' >> /etc/bash.bashrc

# HOME is on the disk, so Claude Code's settings, sessions and your clone survive restarts.
# sh reads the file in ENV and bash reads /etc/bash.bashrc: both open into the tmux session.
ENV HOME=/data/home \
  SHELL=/bin/bash \
  ENV=/opt/claude/shell-init.sh \
  DISABLE_AUTOUPDATER=1

USER 1000:1000
WORKDIR /data
ENTRYPOINT ["tini", "--"]
CMD ["/opt/claude/start.sh"]
```

`start.sh` runs when the instance starts. It checks the variables, answers Claude Code's first-run questions, signs Git in with the token, clones the repository once, and starts the session:

```bash start.sh theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
#!/bin/bash
set -euo pipefail

: "${GITHUB_REPO:?Set GITHUB_REPO to the repository to work on, as owner/name}"
: "${GH_TOKEN:?Set GH_TOKEN to a fine-grained GitHub token}"
if [ -z "${ANTHROPIC_API_KEY:-}${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
  echo "Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN" >&2
  exit 1
fi

# Answer Claude Code's first-run questions (theme, using the API key, trusting the
# repository), so the web terminal opens on a ready prompt.
mkdir -p "$HOME"
config=$HOME/.claude.json
[ -s "$config" ] || echo '{}' > "$config"
key=${ANTHROPIC_API_KEY:-}
jq --arg key "${key: -20}" '.hasCompletedOnboarding = true
  | .customApiKeyResponses.approved = ((.customApiKeyResponses.approved // []) + [$key] | unique)
  | .projects["/data/repo"].hasTrustDialogAccepted = true' "$config" > "$config.tmp"
mv "$config.tmp" "$config"

# git pushes with GH_TOKEN and commits as the token's GitHub user.
gh auth setup-git
login=$(gh api user --jq .login)
git config --global user.name "$login"
git config --global user.email "$(gh api user --jq .id)+$login@users.noreply.github.com"
[ -d /data/repo/.git ] || gh repo clone "$GITHUB_REPO" /data/repo

if ! tmux has-session -t claude 2>/dev/null; then
  # A shell runs in the session, so quitting Claude Code leaves a prompt to start it again.
  tmux new-session -d -s claude -c /data/repo
  tmux send-keys -t claude claude Enter
fi
echo "Claude Code is running in tmux. Open the Web terminal to join it."
exec sleep infinity
```

`shell-init.sh` is what makes the web terminal open in Claude Code. Inside tmux, `TMUX` is set, so the shells that tmux starts don't try to attach again:

```sh shell-init.sh theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
# Interactive shells source this file. Outside tmux, join Claude Code's session.
if [ -z "${TMUX:-}" ] && tmux has-session -t claude 2>/dev/null; then
  tmux attach -t claude
fi
```

`tmux.conf` turns on the mouse, so the scroll wheel scrolls back through the session, and keeps 50,000 lines of it:

```text tmux.conf theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
set -g mouse on
set -g history-limit 50000
```

`managed-settings.json` holds Claude Code's permission rules (see [Set permission rules](#set-permission-rules)):

```json managed-settings.json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "permissions": {
    "deny": [
      "Bash(git push --force*)",
      "Bash(git push -f *)",
      "Bash(gh pr merge *)",
      "Bash(gh repo delete *)"
    ],
    "disableBypassPermissionsMode": "disable"
  }
}
```

`.dockerignore` keeps Git's history and local environment files out of the build:

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
.env*
Dockerfile
.dockerignore
```

Make the start script executable, then commit everything and push it to the private repository for this project:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
chmod +x start.sh
git init
git add .
git commit -m "Run Claude Code on Sevalla"
git branch -M main
git remote add origin https://github.com/acme/claude-workspace.git
git push -u origin main
```

| Line or choice | Why |
| - | - |
| `node:24-trixie-slim` | The official Node.js 24 image on Debian 13. npm installs Claude Code, and Node.js 24 is there for JavaScript projects. The tag follows Node.js 24's patch releases, so each build gets security fixes; a digest would stay on one release. |
| `ca-certificates gh git jq less tini tmux` | `gh` and `git` talk to GitHub over HTTPS and need the root certificates. `jq` edits Claude Code's configuration in `start.sh`, `less` pages `git` and `gh` output, and `tini` passes stop signals on. The `hadolint ignore` comment skips the linter's rule that asks for pinned package versions: the `trixie` tag holds them to Debian 13's, and a pinned patch version would break the build when Debian ships a fix. |
| `CLAUDE_CODE_VERSION=2.1.308` and `DISABLE_AUTOUPDATER=1` | Claude Code never updates itself in the instance, so it changes only when you change this line and deploy. `npm view @anthropic-ai/claude-code dist-tags` shows the current `stable` and `latest` versions. |
| `/etc/claude-code/managed-settings.json` | Claude Code reads it as [managed settings](https://code.claude.com/docs/en/settings#settings-precedence), which no other settings file overrides. It belongs to root, so Claude Code can't change it, and your own choices, such as `/model`, still save to `~/.claude/settings.json` on the disk. |
| `ENV` and `/etc/bash.bashrc` | `sh` reads the file named in `ENV` when it starts interactively, and `bash` reads `/etc/bash.bashrc`. Scripts and the start command run non-interactively, so they don't attach to tmux. |
| `HOME=/data/home` | The disk is mounted at `/data`, so `~/.claude`, `~/.claude.json` and Git's settings outlive the instance. |
| `USER 1000:1000` | Claude Code and everything it runs use the image's unprivileged `node` user. The files in `/opt/claude` and `/etc` belong to root, so they can't be changed from a session. Sevalla mounts the disk writable for this user. |
| `tini` | `sleep` as the first process would ignore `SIGTERM`, so each restart would wait until the instance is killed. `tini` passes the signal on, and the instance stops at once. |
| No `EXPOSE` | Nothing listens on a port. |

> [!NOTE]
>
> [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) can build a similar image from a `package.json` that depends on `@anthropic-ai/claude-code` and an `ingot.json` whose `packages.run` lists `ca-certificates`, `gh`, `git`, `jq` and `tmux`. Ingot's Node.js image has no root certificates for programs other than Node.js, so `gh` fails with `x509: certificate signed by unknown authority` without `ca-certificates`. `HOME`, `ENV` and `DISABLE_AUTOUPDATER` then become environment variables, and the start script has to write the shell hook to `~/.bashrc`, because the image's system folders belong to root. The Dockerfile keeps all of it in one file.

## Deploy to Sevalla

These steps create the application, switch it to the Dockerfile, add the variables, turn HTTP traffic off and add the disk before the first deployment.

**1. Create the application**

In the sidebar, click **Applications**, then click **Create** > **Application**. Under **Source**, click **Private repository**, choose the **Git provider**, the `claude-workspace` **Repository** and the `main` **Branch**, and turn off **Auto-deploy**. Under **Location**, choose a **Data center**. Choose **S1** as the **Size**, then click **Create application**. The application opens on its **Overview** tab with the status **Not deployed yet**.

**2. Choose the Dockerfile build strategy**

On the **Settings** tab, in the **Build strategy** section, click **Update build strategy**. Click **Dockerfile**, keep the **Dockerfile path** `Dockerfile` and the **Docker context** `.`, then click **Update build strategy**.

**3. Add the environment variables**

Click the **Environment variables** tab, then click **Add environment variables**. Add `ANTHROPIC_API_KEY`, `GH_TOKEN` and `GITHUB_REPO` (see [Environment variables](#environment-variables)), clear **Available during buildtime** so that none of them reaches the build, keep **Available during runtime**, and click **Add environment variables**.

**4. Turn HTTP traffic off**

Click the **Networking** tab. In **Expose HTTP traffic**, click **Disable**, then click **Disable HTTP** in the **Disable HTTP traffic** dialog. The application's domains show **HTTP is disabled**.

**5. Add the disk**

Click the **Disks** tab, then click **Create disk**. Choose the web process as the **Process**, enter `/data` as the **Path**, keep the **Size** at 10 GB, and click **Create disk**.

**6. Deploy**

Click the **Overview** tab, then click **Deploy now** under **Recent deployments**. Check the **Branch** and click **Deploy**.

![The Create disk sheet with the Process, Path and Size fields](https://assets.sevalla.com/screenshots/light/app-disks-create.png)

**Auto-deploy** stays off: a deployment restarts the instance, which ends whatever Claude Code is doing. With it on, every push to this repository's `main` branch would do that in the middle of a task. Deploy by hand when no task is running. Don't point `GITHUB_REPO` at the repository this application deploys from either, or Claude Code's own pushes to `main` would be the next deployment.

## Verify the deployment

1. On the **Deployments** tab, click the deployment. Its **Build log** shows `📦 Building and pushing docker image using Dockerfile...` and ends with `🚀 Web has been deployed successfully.`, and its **Dockerfile** tab shows your `Dockerfile`.

2. Wait for the deployment's status to change to **Deployed**. On the first start, the [runtime logs](https://docs.sevalla.com/applications/logs.md) show:

   ```text Runtime logs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   Cloning into '/data/repo'...
   Claude Code is running in tmux. Open the Web terminal to join it.
   ```

3. Click the **Web terminal** tab. The terminal opens in Claude Code, ready for a task, with the model and `/data/repo` in its header and `[claude] 0:claude*` in tmux's status bar at the bottom.

![The web terminal with one tab in the web process, a Live status in the toolbar and a bash session](https://assets.sevalla.com/screenshots/light/app-web-terminal.png)

To check the same with the API, read the deployment with [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) and its log with [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs), then run commands in the instance with [Run a command in a process](https://api-docs.sevalla.com/v3/applications/run-a-command-in-a-process). `APP_ID`, `WEB_ID` and `DEPLOYMENT_ID` come from [Deploy with the API](#deploy-with-the-api):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "https://api.sevalla.com/v3/applications/$APP_ID/deployments/$DEPLOYMENT_ID" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .status
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
success
```

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "https://api.sevalla.com/v3/applications/$APP_ID/processes/$WEB_ID/exec" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": ["claude", "--version"]}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"stdout":"2.1.308 (Claude Code)\n","stderr":"","exit_code":0}
```

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "https://api.sevalla.com/v3/applications/$APP_ID/processes/$WEB_ID/exec" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": ["tmux", "list-sessions", "-F", "#{session_name}"]}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"stdout":"claude\n","stderr":"","exit_code":0}
```

## Work with Claude Code

Type a task at Claude Code's prompt, for example `Add a health check endpoint with a test, then open a pull request`. Claude Code works in `/data/repo`, commits on a branch, pushes it with `GH_TOKEN` and opens the pull request with `gh pr create`. Its status bar shows the pull request number, and you review and merge it on GitHub.

* **Leave the session running.** Close the browser tab, or press **Ctrl-b** then **d** to detach and get a plain shell in the same instance. Claude Code keeps working either way.
* **Come back.** Open the **Web terminal** tab again, from any browser. Every new tab or pane joins the same session; press **Ctrl-b** then **d** in it for a plain shell instead.
* **From your own terminal.** The [Sevalla CLI](https://docs.sevalla.com/developer-tools/cli.md) opens the same session: `sevalla apps processes terminal <web-process-id> --app-id <app-id>`.
* **Quit and restart Claude Code.** `/exit` (or **Ctrl-c** twice) leaves a shell in the session. Run `claude` to start a new conversation, or `claude --continue` to pick up the last one.
* **Work on another repository.** The token reaches only the repositories you gave it. Add another one to the token, then clone it under `/data` from the session's shell, for example `gh repo clone acme/storefront-docs /data/storefront-docs`, and start `claude` there. Claude Code asks once whether you trust the folder.

To see what the shell sees, detach and run `printenv`: every runtime environment variable, including the keys, is there. Claude Code can read them too.

## Choose the model

With an API key, Claude Code starts on Opus 5.5. Change the model for the current session with `/model`, which also saves your choice to `~/.claude/settings.json` on the disk for the next sessions. To fix the model for every session instead, set `ANTHROPIC_MODEL`, for example to `claude-sonnet-5-5`, and deploy: the variable outranks what `/model` saved. See [Model configuration](https://code.claude.com/docs/en/model-config) for aliases and effort levels, and [Claude pricing](https://platform.claude.com/docs/en/about-claude/pricing) for the price of each model.

## Use a Claude subscription

With a Claude Pro, Max, Team or Enterprise plan, Claude Code can use your subscription instead of an API key. On your computer, run:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
claude setup-token
```

It opens the same browser sign-in as `/login` and prints a token that's valid for one year. Add it as `CLAUDE_CODE_OAUTH_TOKEN` (available during runtime only), delete `ANTHROPIC_API_KEY`, and deploy. Usage then counts against your plan's limits. Anthropic's terms allow subscription sign-in for your own use of Claude Code: don't share the application with other people while it holds your token. See [Authentication](https://code.claude.com/docs/en/authentication#generate-a-long-lived-token) and [Legal and compliance](https://code.claude.com/docs/en/legal-and-compliance).

## Set permission rules

`managed-settings.json` holds deny rules that Claude Code applies to every session, whatever mode it's in: it never runs `gh pr merge`, `gh repo delete` or a force push, and `disableBypassPermissionsMode` turns off `--dangerously-skip-permissions`. In Claude Code, `/permissions` lists them on its **Deny** tab, marked as coming from managed settings that can't be modified.

Claude Code 2.1.308 starts in auto mode, where a classifier checks each action for risky commands and prompt injection before it runs and asks you when it's unsure. Press **Shift+Tab** to switch modes. To change the rules, update `managed-settings.json` and deploy; rules you add with `/permissions` save to the disk and last across restarts. See [Configure permissions](https://code.claude.com/docs/en/permissions).

Deny rules stop the commands Claude Code usually writes, not every way of reaching the same result. The limits that hold are the token's: the one repository it can reach, its two permissions, and branch protection on `main` (a [ruleset](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets), which private repositories get with GitHub Pro, Team or Enterprise Cloud).

## What survives a restart or a deployment

A restart and a deployment both replace the instance, so the session ends and `start.sh` starts a new one. Everything on the disk stays:

| What | After a restart or a deployment |
| - | - |
| `/data/repo`: the clone, its current branch and uncommitted changes | Kept |
| `/data/home`: Claude Code's settings, conversation history and `~/.claude.json` | Kept. `/resume` or `claude --continue` reopens a conversation |
| Files outside `/data`, such as `/tmp` | Gone |
| The tmux session and a running Claude Code task | Ended. A new session starts with a new conversation |
| Shells open in the web terminal | Ended |

A process with a disk runs one instance, and Sevalla stops it before the new one starts, so each deployment has a short gap. A restart, from **Deploy now** with **Skip building (restart latest)**, takes about 20 seconds; a deployment that builds takes 1 to 2 minutes. See [Persistent storage](https://docs.sevalla.com/applications/disks.md#how-disks-work).

To stop paying for the instance while you don't need it, suspend the application on its **Settings** tab, in the **Danger zone**. Its disk and settings stay, and activating it starts a new session. See [Suspend an application](https://docs.sevalla.com/applications/settings.md#suspend-an-application).

## Choose the size and cost

Claude Code needs between 150 and 350 MB of memory for itself, and uses more when the instance has more. It fits in **H1** (0.3 GB) for small tasks, but everything it runs for you, such as installing dependencies, building and running tests, shares the same memory. **S1** (1 GB) is a good start; move to a larger size when your project's builds need it. A process with a disk can't autoscale, so scale it up, not out. See [Scaling](https://docs.sevalla.com/applications/scaling.md#instance-sizes).

| Item | Monthly price |
| - | - |
| **S1** web process | \$10 |
| 10 GB disk | \$3 |
| Claude API | Billed by Anthropic per token, or included in your Claude plan |

Prices are from [Pricing](https://docs.sevalla.com/billing/pricing.md) and exclude tax. Instances and disks are billed by the second while they run.

## Update Claude Code

Change `CLAUDE_CODE_VERSION` in the `Dockerfile`, commit and push, then click **Deploy now** when no task is running. The deployment builds a new image and replaces the instance; your disk, settings and conversations stay. `claude update` in the instance fails with `Error: Insufficient permissions to install update`, because the global npm folder belongs to root: the version comes only from the image.

## Secure the session

* **HTTP traffic stays off.** Nothing on the internet reaches the instance. You reach it through the dashboard's web terminal, the CLI or the API, all of which need your Sevalla account or an API key.
* **Access to the application is access to the keys.** Anyone who can open the application's web terminal can use Claude Code with your keys and read them with `printenv`. Give access to the application with care. See [Web terminal](https://docs.sevalla.com/applications/web-terminal.md).
* **Keys stay out of the image.** The variables are available during runtime only, so they're never in a build log or an image layer.
* **The token is the boundary.** One repository, contents and pull requests only, an expiry date, and a separate GitHub user if you want to see what Claude Code did. Set a spend limit on the Claude Console workspace of the API key.
* **Read what you merge.** Content Claude Code reads, such as issues, pull requests, web pages and dependencies, can contain instructions meant for it. Auto mode's classifier checks each action, and you review every pull request before it reaches `main`.
* **Outbound traffic is open.** The instance can reach any address on the internet, as Claude Code needs to call the API and GitHub.

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| The runtime logs show `/opt/claude/start.sh: line 4: GITHUB_REPO: Set GITHUB_REPO to the repository to work on, as owner/name`, and the instance restarts | `GITHUB_REPO` isn't set, or isn't **Available during runtime** | Add it on the **Environment variables** tab, then deploy |
| The runtime logs show `Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN` | Neither key is set for runtime | Add one of them, then deploy |
| The runtime logs show `GraphQL: Could not resolve to a Repository with the name 'acme/storefront'. (repository)` | `GITHUB_REPO` is misspelled, or the token can't reach the repository | Fix the name, or add the repository to the token, then deploy |
| The runtime logs show `gh: Bad credentials (HTTP 401)` | The token is wrong, revoked or expired | Create a new token, update `GH_TOKEN`, then deploy |
| The web terminal opens a plain shell, not Claude Code | The tmux session ended, for example after `exit` in its shell | Restart the application from **Deploy now** with **Skip building (restart latest)** |
| Claude Code asks whether you trust the folder | It started outside `/data/repo` | Confirm, or start it in `/data/repo` |
| Your work is gone after a deployment | The disk isn't mounted at `/data`, so `HOME` and the clone were on the instance | On the **Disks** tab, check the disk's **Path** is `/data`, then deploy |

## Deploy with the API

This sequence creates the same application as [Deploy to Sevalla](#deploy-to-sevalla) with the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md). Run the steps in order in one shell: later steps use the IDs that earlier steps save in shell variables. You need:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) with the **Company developer** or **Company admin** role.
* This guide's repository pushed as in [Create the project](#create-the-project), and its Git provider connected to your Sevalla user with access to the repository. Create application checks this: without access, it fails with `400` and `Cannot access repository <repository URL>: Repository not found or you don't have access.`
* Your Claude API key, the GitHub token, and `curl` and `jq`.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
export SEVALLA_API_KEY=<your Sevalla API key> ANTHROPIC_API_KEY=<your Claude API key> GH_TOKEN=<your GitHub token>
API=https://api.sevalla.com/v3
REPO=https://github.com/acme/claude-workspace
```

**1. Choose the location**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns every location with its ID and its region ID in `location`. Print them, then save the one you want by its region ID, here `eu-frankfurt-1` (Frankfurt, Germany; see [Locations](https://docs.sevalla.com/platform/locations.md)):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "$API/resources/clusters" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[] | "\(.location)  \(.display_name)"'
CLUSTER_ID=$(curl -s "$API/resources/clusters" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.location == "eu-frankfurt-1") | .id')
```

CLI: `sevalla resources clusters`. MCP: `get_platform_options`.

**2. Look up the disk size**

[List disk resource types](https://api-docs.sevalla.com/v3/resources/list-disk-resource-types) (`listDiskResourceTypesV3`) returns the disk sizes. Save the 10 GB one:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DISK_SIZE_ID=$(curl -s "$API/resources/disk-resource-types" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.size_gib == 10) | .id')
```

CLI: `sevalla resources disk-types`. MCP: `sevalla_api_read` with `listDiskResourceTypesV3`.

**3. Create the application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates the application with a web process at **S1** and automatic deployments off, and doesn't deploy it:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_ID=$(curl -s -X POST "$API/applications" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg cluster "$CLUSTER_ID" --arg repo "$REPO" \
    '{display_name: "claude-workspace", cluster_id: $cluster, source: "privateGit", git_type: "github",
      repo_url: $repo, default_branch: "main"}')" \
  | jq -r .id)
```

CLI: `sevalla apps create --name claude-workspace --cluster "$CLUSTER_ID" --source privateGit --git-type github --repository "$REPO" --branch main`. MCP: `create_application` with `deploy: false`.

**4. Build from the Dockerfile**

[Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) sets the build strategy:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH "$API/applications/$APP_ID" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d '{"build_type": "dockerfile", "dockerfile_path": "Dockerfile", "docker_context": "."}' | jq -r .build_type
```

It prints `dockerfile`. CLI: `sevalla apps update "$APP_ID" --build-type dockerfile --dockerfile-path Dockerfile`. MCP: `sevalla_api_update` with `updateApplicationV3` and the same body.

**5. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`), once per variable, each available during runtime only:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for pair in "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" "GH_TOKEN=$GH_TOKEN" "GITHUB_REPO=acme/storefront"; do
  curl -s -X POST "$API/applications/$APP_ID/env-vars" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
    -d "$(jq -n --arg key "${pair%%=*}" --arg value "${pair#*=}" \
      '{key: $key, value: $value, is_runtime: true, is_buildtime: false}')" | jq -r .key
done
```

CLI: not available for runtime-only variables; `sevalla apps env-vars create` has no runtime or buildtime option and makes a variable available during both. MCP: `set_environment_variables` with `runtime: true` and `buildtime: false` for each variable.

**6. Find the web process**

[List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) returns the web process that the application came with:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB_ID=$(curl -s "$API/applications/$APP_ID/processes" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.data[] | select(.type == "web") | .id')
```

CLI: `sevalla apps processes list --app-id "$APP_ID"`. MCP: `get_application`.

**7. Turn HTTP traffic off**

[Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) with `is_ingress_enabled: false`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH "$API/applications/$APP_ID/processes/$WEB_ID" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d '{"is_ingress_enabled": false}' | jq -r .is_ingress_enabled
```

It prints `false`. CLI: not available. MCP: `sevalla_api_update` with `updateProcessV3` and the same body.

**8. Add the disk**

[Attach or update process disk](https://api-docs.sevalla.com/v3/applications/attach-or-update-process-disk) (`updateProcessDiskV3`) mounts a 10 GB disk at `/data` from the next deployment:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PUT "$API/applications/$APP_ID/processes/$WEB_ID/disk" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg size "$DISK_SIZE_ID" '{resource_type_id: $size, mount_path: "/data"}')" | jq -c .
```

It prints the disk, with `"size_gib":10` and `"mount_path":"/data"`. CLI: `sevalla apps processes disk update "$WEB_ID" --app-id "$APP_ID" --resource-type "$DISK_SIZE_ID" --mount-path /data`. MCP: `sevalla_api_update` with `updateProcessDiskV3` and the same body.

**9. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`) builds the latest commit on `main`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST "$API/applications/$APP_ID/deployments" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" -d '{}' | jq -r .id)
```

CLI: `sevalla apps deployments trigger "$APP_ID"`. MCP: `trigger_deployment`.

**10. Wait for the deployment**

[Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) every 15 seconds, for up to 30 minutes, until the status is final. It prints `success` when the instance runs:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 120); do
  STATUS=$(curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.status // empty')
  case $STATUS in success|failed|cancelled|skipped) break ;; esac
  sleep 15
done
echo "$STATUS"
```

If it's `failed`, read the build log with [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`): `curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?limit=5000" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message'`. CLI: `sevalla apps deployments get "$DEPLOYMENT_ID" --app-id "$APP_ID"` and `sevalla apps deployments logs "$DEPLOYMENT_ID" --app-id "$APP_ID"`. MCP: `get_deployment` and `get_deployment_logs`.

**11. Check the session**

[Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`getApplicationRuntimeLogsV3`) shows the start script's last line, and [Run a command in a process](https://api-docs.sevalla.com/v3/applications/run-a-command-in-a-process) (`execProcessCommandV3`) lists the tmux session:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "$API/applications/$APP_ID/runtime-logs?search=Claude%20Code%20is%20running" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message'
curl -s -X POST "$API/applications/$APP_ID/processes/$WEB_ID/exec" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": ["tmux", "list-sessions", "-F", "#{session_name}"]}' | jq -r .stdout
```

They print `Claude Code is running in tmux. Open the Web terminal to join it.` and `claude`. CLI: `sevalla apps logs runtime "$APP_ID" --search "Claude Code is running"` and `sevalla apps processes exec "$WEB_ID" --app-id "$APP_ID" -- tmux list-sessions -F "#{session_name}"`. MCP: `search_runtime_logs`, and `sevalla_api_update` with `execProcessCommandV3`.

Then open the **Web terminal** tab, or join the session from your terminal with `sevalla apps processes terminal "$WEB_ID" --app-id "$APP_ID"`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.