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

# Deploying a coding agent with opencode on Upsun

> Deploy opencode as a self-hosted AI coding agent with headless Chrome for web automation and performance testing.

This tutorial covers deploying [opencode](https://opencode.ai), a self-hosted AI coding agent, on Upsun. The setup includes a headless Chrome service that agents can use for web automation, performance testing, and screenshots.

You'll learn how to work around read-only containers using mounts, connect services via MCP (Model Context Protocol), and configure specialized agents.

## What you're building

A web-accessible coding agent that runs in your browser. You open the web UI, and you have an AI assistant that can read files, run commands, write code, and talk to a headless browser. Four agents come pre-configured: one for managing Upsun deployments, one for security reviews, one for screenshots, and one for performance analysis.

The Chrome integration is where it gets interesting. opencode supports MCP servers, and chrome-devtools-mcp exposes browser automation as tools. Your agent can navigate pages, take screenshots, record performance traces without you writing puppeteer scripts. The agent calls the tool; the MCP server handles the browser stuff.

## Why the setup looks like this

Upsun containers are read-only. You can't write to `/app` at runtime. This is a problem because opencode needs to write files: configuration, auth tokens, project state. And the whole point of a coding agent is writing code.

The solution is mounts. We carve out writable directories backed by persistent storage:

* `.local` for opencode's internal state
* `.config/opencode` for configuration
* `project` for the actual code the agent works on

Without these mounts, opencode crashes on first write. I spent a while debugging this before realizing the issue was the read-only filesystem. The error messages don't make it obvious.

## Architecture

```
┌──────────────────────────────────────────────────────────────┐
│                        Upsun Project                          │
│                                                              │
│  ┌─────────────────────┐         ┌─────────────────────┐    │
│  │      opencode       │   MCP   │   chrome-headless   │    │
│  │    (Node.js 24)     │────────▶│     (Chrome 120)    │    │
│  │                     │         │                     │    │
│  │  - Web UI on :8080  │         │  - Remote debugging │    │
│  │  - 4 custom agents  │         │  - Puppeteer target │    │
│  │  - Upsun CLI        │         └─────────────────────┘    │
│  │                     │                                     │
│  │  Mounts:            │                                     │
│  │  - .local           │                                     │
│  │  - .config/opencode │                                     │
│  │  - project/         │                                     │
│  └─────────────────────┘                                     │
└──────────────────────────────────────────────────────────────┘
```

opencode connects to Chrome via MCP. The chrome-devtools-mcp package talks to Chrome's remote debugging protocol. Upsun exposes the Chrome service URL through environment variables, which we inject into the MCP config at deploy time.

## Prerequisites

You need the [Upsun CLI](/cli), Git, and at least one LLM API key (OpenAI, Anthropic, or Google).

## Project structure

```
03-coding-agent/
├── scripts/
│   ├── setup_auth.sh           # Builds auth.json from env vars
│   ├── install_upsun_cli.sh    # Installs Upsun CLI in container
│   └── create_opencode_project.sh  # Initializes project directory
├── opencode.json               # Agent configurations
└── .upsun/
    └── config.yaml             # Upsun deployment config
```

## The configuration files

### opencode.json

This file defines the MCP servers and custom agents:

[View source on GitHub](https://github.com/upsun/snippets/tree/main/examples/ai-03-coding-agent/opencode.json)

```json theme={null}
{
  "$schema": "https://opencode.ai/config.json",
  "theme": "catppuccin",
  "mcp": {
    "chrome": {
      "type": "local",
      "command": ["npx", "-y", "chrome-devtools-mcp@latest", "--browser-url=http://CHROME"],
      "enabled": true
    }
  },
  "agent": {
    "upsun": {
      "description": "Manages Upsun Platform-as-a-Service projects using the Upsun CLI.",
      "prompt": "You are an expert in managing Upsun Platform-as-a-Service projects...",
      "tools": {
        "bash": true,
        "read": false,
        "write": false,
        "edit": false
      }
    },
    "security-review": {
      "description": "Performs security review of codebase, checking dependencies vulnerabilities and static code analysis",
      "prompt": "You are a security expert. Conduct a thorough security review...",
      "tools": {
        "bash": true,
        "read": true,
        "write": false,
        "edit": false
      }
    },
    "screenshot-agent": {
      "description": "Creates screenshots of deployed application pages using puppeteer",
      "prompt": "You are a screenshot agent. Given a URL, create a Node.js script...",
      "tools": {
        "bash": true,
        "read": false,
        "write": true,
        "edit": false
      }
    },
    "performance-agent": {
      "description": "Analyzes performance metrics of web pages using chrome-mcp",
      "prompt": "You are a performance agent. Given a URL, use chrome-mcp tools...",
      "tools": {
        "bash": false,
        "read": false,
        "write": false,
        "edit": false,
        "chrome_": true
      }
    }
  }
}
```

The `http://CHROME` placeholder gets replaced at deploy time with the actual Chrome service URL. Each agent has restricted tools: the upsun agent only gets bash (to run CLI commands), the security agent gets bash and read (to scan files), the screenshot agent gets bash and write (to save images), and the performance agent only gets chrome tools.

Why restrict tools? So agents stay in their lane. You don't want the security scanner accidentally modifying files. Least privilege.

### .upsun/config.yaml

[View source on GitHub](https://github.com/upsun/snippets/tree/main/examples/ai-03-coding-agent/.upsun/config.yaml)

```yaml theme={null}
applications:
  opencode:
    type: "composable:25.11"
    container_profile: HIGH_MEMORY
    stack:
      runtimes:
        - "nodejs@24"
    relationships:
      chrome:
        service: chrome
        endpoint: http
    hooks:
      build: |
        set -xe
        npm install -g opencode-ai
        npm install -g puppeteer

        # Install Upsun CLI
        bash scripts/install_upsun_cli.sh

        if [ -f /app/.gitconfig ]; then
          git config --global init.defaultBranch main
        fi
      deploy: |
        mkdir -p /app/.local/share/opencode /app/.config/opencode /app/.local/share/opencode/storage/project/

        # Setting up auth.json for multiple providers
        bash scripts/setup_auth.sh

        # Always override the configuration
        cp opencode.json /app/.config/opencode/
        sed -i "s|http://CHROME|$CHROME_SCHEME://$CHROME_IP:$CHROME_PORT|g" /app/.config/opencode/opencode.json

        # Init project folder
        if [ ! -d /app/project/.git ]; then
          cd /app/project && git init
        fi

        # Create opencode project file
        bash scripts/create_opencode_project.sh

        upsun auth:info
    web:
      commands:
        start: 'opencode --port $PORT --hostname 0.0.0.0 web'
    mounts:
      '.local':
        source: storage
        source_path: files/local
      '.config/opencode':
        source: storage
        source_path: files/local
      'project':
        source: storage
        source_path: files/project
services:
  chrome:
    type: chrome-headless:120
routes:
   "https://{default}/":
    type: upstream
    upstream: "opencode:http"
```

We use `composable:25.11` instead of a fixed runtime type because it lets us pick `nodejs@24` from the stack.

HIGH\_MEMORY profile because AI coding agents are surprisingly memory-hungry. The models aren't running locally, but the tools and Chrome connections add up.

The `relationships.chrome` entry exposes `CHROME_SCHEME`, `CHROME_IP`, and `CHROME_PORT` as environment variables. The deploy hook uses sed to inject these into the opencode config. It's a bit hacky, yes. But it works, and I couldn't find a cleaner way to get dynamic service URLs into a static JSON config.

The deploy hook does a lot: create directories, generate auth.json, copy and patch config, init git, create project file. All of this runs before the app starts. Verbose, but necessary because containers are read-only and opencode expects certain files to exist.

## The helper scripts

### scripts/setup\_auth.sh

[View source on GitHub](https://github.com/upsun/snippets/tree/main/examples/ai-03-coding-agent/scripts/setup_auth.sh)

```bash theme={null}
#!/bin/bash

AUTH_JSON="{"
FIRST=true

# Z.AI
if [ -n "$Z_API_KEY" ]; then
  if [ "$FIRST" = false ]; then AUTH_JSON="${AUTH_JSON},"; fi
  AUTH_JSON="${AUTH_JSON}\"zai-coding-plan\":{\"type\":\"api\",\"key\":\"$Z_API_KEY\"}"
  FIRST=false
fi

# Gemini
if [ -n "$GEMINI_API_KEY" ]; then
  if [ "$FIRST" = false ]; then AUTH_JSON="${AUTH_JSON},"; fi
  AUTH_JSON="${AUTH_JSON}\"google-vertex-ai\":{\"type\":\"api\",\"key\":\"$GEMINI_API_KEY\"}"
  FIRST=false
fi

# Claude
if [ -n "$CLAUDE_API_KEY" ]; then
  if [ "$FIRST" = false ]; then AUTH_JSON="${AUTH_JSON},"; fi
  AUTH_JSON="${AUTH_JSON}\"anthropic\":{\"type\":\"api\",\"key\":\"$CLAUDE_API_KEY\"}"
  FIRST=false
fi

# OpenAI
if [ -n "$OPENAI_API_KEY" ]; then
  if [ "$FIRST" = false ]; then AUTH_JSON="${AUTH_JSON},"; fi
  AUTH_JSON="${AUTH_JSON}\"openai\":{\"type\":\"api\",\"key\":\"$OPENAI_API_KEY\"}"
  FIRST=false
fi

AUTH_JSON="${AUTH_JSON}}"
echo "$AUTH_JSON" > /app/.local/share/opencode/auth.json
```

This builds auth.json from environment variables. Set whichever API keys you have, and opencode uses them. You can set multiple and switch in the UI.

### scripts/install\_upsun\_cli.sh

[View source on GitHub](https://github.com/upsun/snippets/tree/main/examples/ai-03-coding-agent/scripts/install_upsun_cli.sh)

```bash theme={null}
#!/bin/bash
set -xe

UPSUN_VERSION="5.8.0"
UPSUN_URL="https://github.com/upsun/cli/releases/download/${UPSUN_VERSION}/upsun_${UPSUN_VERSION}_linux_amd64.tar.gz"
INSTALL_DIR="/app/.global/bin"

mkdir -p "$INSTALL_DIR"
curl -L "$UPSUN_URL" -o "/tmp/upsun.tar.gz"
tar -xzf "/tmp/upsun.tar.gz" -C "/tmp"
mv "/tmp/upsun" "$INSTALL_DIR/upsun"
chmod +x "$INSTALL_DIR/upsun"

export PATH="$INSTALL_DIR:$PATH"
"$INSTALL_DIR/upsun" --version

echo "Upsun CLI installed successfully"
```

The upsun agent needs the CLI. We download it from GitHub releases.

### scripts/create\_opencode\_project.sh

[View source on GitHub](https://github.com/upsun/snippets/tree/main/examples/ai-03-coding-agent/scripts/create_opencode_project.sh)

```bash theme={null}
#!/bin/bash
set -xe

PROJECT_DIR="/app/.local/share/opencode/storage/project"
PROJECT_FILE_PATH="/app/project"

if ! ls "$PROJECT_DIR"/*.json >/dev/null 2>&1; then
  UUID=$(openssl rand -hex 20)
  TIMESTAMP=$(date +%s%3N)
  PROJECT_JSON="{\"id\":\"${UUID}\",\"worktree\":\"${PROJECT_FILE_PATH}\",\"vcs\":\"git\",\"sandboxes\":[],\"time\":{\"created\":${TIMESTAMP},\"updated\":${TIMESTAMP}},\"icon\":{\"color\":\"mint\"}}"
  echo "$PROJECT_JSON" > "$PROJECT_DIR/${UUID}.json"
  echo "Created opencode project file: $PROJECT_DIR/${UUID}.json"
else
  echo "Opencode project file already exists"
fi
```

opencode expects a project file pointing to the working directory. This creates one if missing.

## Deployment

### Initialize the project

```bash theme={null}
cd 03-coding-agent
git init
git add .
git commit -m "Initial commit: opencode coding agent"
```

### Create Upsun project

```bash theme={null}
upsun login
upsun project:create
```

Pick a region close to you since you'll be interacting with the web UI.

### Set API keys

Set at least one LLM provider:

```bash theme={null}
# Anthropic (Claude)
upsun variable:create \
  --level project \
  --name env:CLAUDE_API_KEY \
  --value "sk-ant-..." \
  --sensitive true

# Or OpenAI
upsun variable:create \
  --level project \
  --name env:OPENAI_API_KEY \
  --value "sk-..." \
  --sensitive true

# Or Google
upsun variable:create \
  --level project \
  --name env:GEMINI_API_KEY \
  --value "..." \
  --sensitive true
```

You can set multiple. opencode lets you switch.

If you want the upsun agent to manage projects, you also need an API token:

```bash theme={null}
upsun variable:create \
  --level project \
  --name env:UPSUN_CLI_TOKEN \
  --value "your-api-token" \
  --sensitive true
```

Generate one at [console.upsun.com](https://console.upsun.com) under Account Settings > API Tokens.

### Deploy

```bash theme={null}
upsun push
```

Watch the build. It installs opencode, puppeteer, the Upsun CLI, and sets up directories.

When done:

```bash theme={null}
upsun url
```

Opens the opencode web UI.

## Using the agents

### The main interface

opencode runs in your browser. Type and the agent responds. It reads and writes files, runs commands, uses MCP tools.

### Upsun agent

Switch by typing `@upsun`:

```
@upsun list all my projects
@upsun show environments for project xyz123
@upsun create a backup of production
```

It has bash and the CLI installed. Configured with safe defaults.

### Security review agent

```
@security-review scan this project for vulnerabilities
```

Runs `npm audit` or `pip-audit`, scans code, generates a report.

### Screenshot agent

```
@screenshot-agent take screenshots of https://example.com
```

Writes a puppeteer script, runs it, saves screenshots.

### Performance agent

```
@performance-agent analyze the performance of https://example.com
```

Uses chrome-mcp tools directly. Navigates, records traces, reports on Web Vitals. No puppeteer scripts needed.

## Customizing agents

Add your own to `opencode.json`:

```json theme={null}
{
  "agent": {
    "my-agent": {
      "description": "Does something specific",
      "prompt": "You are an expert at...",
      "tools": {
        "bash": true,
        "read": true,
        "write": true,
        "edit": true
      }
    }
  }
}
```

Restrict tools based on what the agent needs. The `chrome_` prefix enables all chrome MCP tools.

Redeploy after changes:

```bash theme={null}
upsun push
```

## Working with code

The `/app/project` directory is your workspace:

```
clone https://github.com/user/repo into /app/project/repo
```

Or SSH in:

```bash theme={null}
upsun ssh
cd /app/project
git clone https://github.com/user/repo
```

The mount persists across deploys.

## Troubleshooting

### "No providers configured"

Check your API keys:

```bash theme={null}
upsun variable:list
```

One of `CLAUDE_API_KEY`, `OPENAI_API_KEY`, or `GEMINI_API_KEY` needs to be set.

### Chrome MCP not connecting

First check if Chrome is running:

```bash theme={null}
upsun ssh
curl -v http://$CHROME_IP:$CHROME_PORT/json/version
```

If that fails, look at service logs:

```bash theme={null}
upsun logs --service chrome
```

### Upsun CLI not working in the agent

You probably forgot the API token:

```bash theme={null}
upsun variable:list | grep UPSUN_CLI_TOKEN
```

Test it:

```bash theme={null}
upsun ssh
upsun auth:info
```

### Out of memory

Try a bigger container:

```bash theme={null}
upsun resources:set --size L
```

### Files disappearing after deploy

You wrote somewhere outside the mounts. Only `.local`, `.config/opencode`, and `project` persist. Everything else in `/app` resets on deploy. This trips people up a lot.

## Why this setup

Chrome runs as a separate service because putting it in the app container would be a mess. Resource conflicts, bloated container, harder to debug. A dedicated service keeps things clean.

MCP instead of puppeteer directly because MCP is how opencode extends itself. Agents call tools. chrome-mcp translates those into browser actions. If you used puppeteer directly, the agent would have to write and run scripts. MCP is cleaner.

Multiple agents with restricted permissions because you don't want one agent doing everything. The security scanner shouldn't write files. The upsun agent shouldn't edit code. Keeps things focused.

The deploy hook is verbose because containers are read-only. We have to set up writable directories and populate config during deploy. Annoying but necessary.

## Resources

* [opencode Documentation](https://opencode.ai/docs)
* [MCP Specification](https://spec.modelcontextprotocol.io/)
* [chrome-devtools-mcp](https://github.com/nicholasgriffintn/chrome-devtools-mcp)
* [Upsun Chrome Headless Service](/docs/add-services/headless-chrome)
* [Upsun Composable Images](/docs/configure-apps/app-reference/composable-image)

For questions, check the [Upsun Community Forum](https://community.upsun.com/) or open an issue in this repo.
