Skip to main content
This tutorial covers deploying opencode, 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 (docs.upsun.com/administration/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
{
  "$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
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
#!/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
#!/bin/bash
set -xe

UPSUN_VERSION="5.8.0"
UPSUN_URL="https://github.com/platformsh/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
#!/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

cd 03-coding-agent
git init
git add .
git commit -m "Initial commit: opencode coding agent"

Create Upsun project

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:
# 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:
upsun variable:create \
  --level project \
  --name env:UPSUN_CLI_TOKEN \
  --value "your-api-token" \
  --sensitive true
Generate one at console.upsun.com under Account Settings > API Tokens.

Deploy

upsun push
Watch the build. It installs opencode, puppeteer, the Upsun CLI, and sets up directories. When done:
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:
{
  "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:
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:
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:
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:
upsun ssh
curl -v http://$CHROME_IP:$CHROME_PORT/json/version
If that fails, look at service logs:
upsun logs --service chrome

Upsun CLI not working in the agent

You probably forgot the API token:
upsun variable:list | grep UPSUN_CLI_TOKEN
Test it:
upsun ssh
upsun auth:info

Out of memory

Try a bigger container:
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

For questions, check the Upsun community forum or open an issue in this repo.
Last modified on March 10, 2026