The launcher of OpenCode in Docker: opencode-docker.sh, image flags and keys

Introduction

This article is part of a larger manual, OpenCode in Docker on Ubuntu 24.04.

The manual shows how to run OpenCode with the --auto option inside a Docker container.

With --auto the agent does not ask you before each command, so the container limits what it can reach: the agent works only in one git worktree, a separate copy of the project.

This article is step 5 (the launcher) of the manual in full.

You never start the container of the agent with your own docker run command.

Every command in the manual that starts the agent uses one script, container/tools/ai/opencode-docker.sh.

The script builds the image and refuses a folder with ignored files.

Then it mounts the worktree, replaces the secret files with empty ones, takes away all root rights and starts OpenCode, a shell or the TUI. The script is written in Bash.

The script

The whole script as it is now. The part that picks the image is the case line of each flag, near the top:

vi container/tools/ai/opencode-docker.sh

#!/usr/bin/env bash # version 0.6.0 # Run OpenCode with --auto in a container that sees container/src of an # agent worktree read-write, and the instructions, docs and tools read-only. # # Usage (repo root of the main checkout): # git worktree add ../site-agent -b agent/<task> # container/tools/ai/opencode-docker.sh ../site-agent # TUI # container/tools/ai/opencode-docker.sh ../site-agent run "<task>" # one task # container/tools/ai/opencode-docker.sh ../site-agent shell # a shell # container/tools/ai/opencode-docker.sh --rw container/tools/deploy ../site-agent run "<task>" # --rw <path>: writable for this run, a path inside the worktree; repeat # it for several paths. Masked files stay empty even under it. # container/tools/ai/opencode-docker.sh --openrouter-key ~/.config/opencode-agent/openrouter.key ../site-agent run -m openrouter/<model> "<task>" # --openrouter-key <file>: an OpenRouter key for this run only, from a # file outside the repo; the container gets it as OPENROUTER_API_KEY. # container/tools/ai/opencode-docker.sh --opencode-key ~/.config/opencode-agent/opencode.key ../site-agent run -m opencode-go/<model> "<task>" # --opencode-key <file>: an OpenCode key (Zen or Go) for this run only, # from a file outside the repo; the container gets it as OPENCODE_API_KEY. # container/tools/ai/opencode-docker.sh --erlang ../site-agent run "<task>" # --erlang: the image of Dockerfile.erlang.opencode, which adds Erlang/OTP # (erl, erlc, escript) for the examples of the Erlang pages. # container/tools/ai/opencode-docker.sh --ruby ../site-agent run "<task>" # --ruby: the image of Dockerfile.ruby.opencode, which adds Ruby (ruby, # irb, gem, erb) for the examples of the Ruby pages. # At start it prints to stderr the model and the file or option it comes # from, the image and the name of the container, between empty lines. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" rw=(); keys=(); dockerfile=Dockerfile.opencode; image=opencode-agent while :; do case "${1:-}" in --rw) rw+=("${2:?--rw needs a path inside the worktree}"); shift 2 ;; --openrouter-key) keys+=(OPENROUTER_API_KEY "${2:?--openrouter-key needs a file}"); shift 2 ;; --opencode-key) keys+=(OPENCODE_API_KEY "${2:?--opencode-key needs a file}"); shift 2 ;; --erlang) dockerfile=Dockerfile.erlang.opencode; image=opencode-agent-erlang; shift ;; --ruby) dockerfile=Dockerfile.ruby.opencode; image=opencode-agent-ruby; shift ;; *) break ;; esac done WT="$(cd "${1:?usage: opencode-docker.sh [--rw <path>]... [--openrouter-key <file>] [--opencode-key <file>] [--erlang | --ruby] <worktree> [run <task> | shell]}" && pwd)" shift [ -d "$WT/container/src" ] || { echo "ERROR: $WT/container/src not found." >&2; exit 1; } # A key goes in through the environment of this run only, never as a # file: one in the repo or the worktree would be mounted, or committed. env=() for ((i = 0; i < ${#keys[@]}; i += 2)); do var="${keys[i]}"; key_file="${keys[i + 1]}" [ -r "$key_file" ] || { echo "ERROR: cannot read $key_file." >&2; exit 1; } case "$(realpath "$key_file")" in "$(realpath "$REPO_ROOT")"/*|"$(realpath "$WT")"/*) echo "ERROR: keep the key file outside the repo and the worktree." >&2; exit 1 ;; esac key="$(tr -d '[:space:]' < "$key_file")" [ -n "$key" ] || { echo "ERROR: $key_file is empty." >&2; exit 1; } export "$var=$key" env+=(-e "$var") done ro=(AGENTS.md .claude/skills skill.md _internal_ai_skill_for_files.md container/docs container/tools container/tests) for p in "${rw[@]}"; do case "$p" in /*|*..*) echo "ERROR: --rw $p: use a path inside the worktree." >&2; exit 1 ;; esac [ -e "$WT/$p" ] || { echo "ERROR: --rw $p: not found in $WT." >&2; exit 1; } done # Only tracked files go in: the ignored ones are logs, visitor data, backups. # Python caches are ignored too, but they hold nothing but compiled tools. ignored="$(git -C "$WT" status --short --ignored -- container "${ro[@]}" "${rw[@]}" \ | grep '^!!' | grep -v '__pycache__/$' || true)" if [ -n "$ignored" ]; then echo "ERROR: $WT holds ignored files; use a fresh worktree:" >&2 echo "$ignored" | head -5 >&2 exit 1 fi in_list() { local x="$1" y; shift; for y in "$@"; do [ "$x" = "$y" ] && return 0; done; return 1; } # What this run uses: the model, where it comes from, the image and the # name of the container. To stderr, so stdout holds only what OpenCode says. config="$SCRIPT_DIR/docker/opencode.jsonc" case "${1:-}" in run) what="one task" ;; shell) what="a shell; OpenCode is not started, the model is for opencode commands in it" ;; *) what="the TUI" ;; esac model=; model_from= if [ "${1:-}" != shell ]; then prev= for a in "$@"; do case "$prev" in -m|--model) model="$a"; model_from="$prev on the command line" ;; esac case "$a" in --model=*) model="${a#--model=}"; model_from="--model on the command line" ;; esac prev="$a" done fi if [ -z "$model" ]; then model="$(sed -n 's/^[[:space:]]*"model"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$config" | head -n 1)" model_from="$config" [ -n "$model" ] || model="the default of OpenCode: the file has no model line" fi container="$image-$(printf '%s' "${WT##*/}" | tr -c 'A-Za-z0-9_.-' '-')-$$" { echo echo "OpenCode in Docker: $what" echo " model: $model" echo " from: $model_from" echo " image: $image, built from container/containerfiles/$dockerfile" echo " container: $container" echo " worktree: $WT" echo } >&2 docker build -q -t "$image" \ --build-arg HOST_UID="$(id -u)" --build-arg HOST_GID="$(id -g)" \ -f "$REPO_ROOT/container/containerfiles/$dockerfile" \ "$REPO_ROOT/container/containerfiles" >/dev/null # Read-write: the docroot. Read-only: what the agent reads and runs, unless # the task named it with --rw. mounts=(-v "$WT/container/src:/work/container/src") for p in "${ro[@]}"; do [ -e "$WT/$p" ] || continue if in_list "$p" "${rw[@]}"; then mounts+=(-v "$WT/$p:/work/$p") else mounts+=(-v "$WT/$p:/work/$p:ro"); fi done # --rw inside a read-only folder: a writable mount on top of it. for p in "${rw[@]}"; do in_list "$p" container/src "${ro[@]}" || mounts+=(-v "$WT/$p:/work/$p") done # Tracked files that the deny rules of the host protect: an empty read-only # file in their place, even under --rw. Edit them by hand on the host. while IFS= read -r f; do mounts+=(-v "$SCRIPT_DIR/docker/masked:/work/$f:ro") done < <(git -C "$WT" ls-files -- container | grep -E '\.env$|creds' || true) mounts+=(-v "$SCRIPT_DIR/docker/opencode.jsonc:/work/opencode.jsonc:ro" -v "$SCRIPT_DIR/docker/CONTAINER.md:/work/CONTAINER.md:ro" -v opencode-agent-data:/home/agent/.local/share/opencode) entry=() case "${1:-}" in run) shift; set -- run --auto "$@" ;; shell) shift; entry=(--entrypoint sh) ;; *) set -- --auto "$@" ;; esac tty=(); [ -t 0 ] && tty=(-t) exec docker run --rm -i "${tty[@]}" --name "$container" \ --cap-drop ALL --security-opt no-new-privileges \ "${env[@]}" "${mounts[@]}" "${entry[@]}" "$image" "$@"

chmod +x container/tools/ai/opencode-docker.sh

The empty file that takes the place of the masked ones:

touch container/tools/ai/docker/masked

What it prints at the start

Before it builds the image and starts the container, the launcher prints what this run uses, with an empty line before and after it.

A task with the default config (user name and folder names replaced):

container/tools/ai/opencode-docker.sh ../site-agent run "<task>"

OpenCode in Docker: one task model: opencode/deepseek-v4.1-flash-free from: /home/me/github/site/container/tools/ai/docker/opencode.jsonc image: opencode-agent, built from container/containerfiles/Dockerfile.opencode container: opencode-agent-site-agent-3532388 worktree: /home/me/github/site-agent

A task in the Erlang image on a model named with -m:

container/tools/ai/opencode-docker.sh --erlang ../site-agent run -m openrouter/deepseek/deepseek-v4.1-flash "<task>"

OpenCode in Docker: one task model: openrouter/deepseek/deepseek-v4.1-flash from: -m on the command line image: opencode-agent-erlang, built from container/containerfiles/Dockerfile.erlang.opencode container: opencode-agent-erlang-site-agent-3530010 worktree: /home/me/github/site-agent

LineWhat it tells you
OpenCode in Docker:what starts: one task for run, the TUI without it, or a shell, where OpenCode is not started
model:the model of the run
from:where that model comes from: the config file with its full path, or -m or --model on the command line
image:the image and the Dockerfile it is built from: the default one, or the one of --erlang or --ruby
container:the name of the container
worktree:the worktree the container works on

While the container runs, docker ps lists it by that name.

Tested with a shell in the Ruby image on a worktree folder named site-agent-demo:

docker ps --filter name=opencode-agent-ruby-site-agent-demo --format '{{.Names}}'

opencode-agent-ruby-site-agent-demo-3530656

What it does

Options

All calls run from the repo root of the main checkout.

The options of the launcher go before the worktree, in any order; the options of opencode run, such as --title, -m and -s, go after run:

Call or optionWhat it does
container/tools/ai/opencode-docker.sh ../site-agentthe TUI of OpenCode
container/tools/ai/opencode-docker.sh ../site-agent run "<task>"one task with --auto, then the container ends
container/tools/ai/opencode-docker.sh ../site-agent shella shell in the same container, as in step 6 (check what the container sees)
--rw <path>one more path writable for this run (when the task is in container/tools: --rw)
--openrouter-key <file>an OpenRouter key for this run (a paid model through OpenRouter)
--opencode-key <file>an OpenCode Zen or Go key for this run (keys for one run)
--erlang, --rubythe image with Erlang or Ruby (the image per run: --erlang and --ruby)

The image per run: --erlang and --ruby

Without a flag a run uses the default image, opencode-agent of Dockerfile.opencode.

A flag picks another Dockerfile and another image name; the mounts, the masks, the keys and the sessions volume stay the same:

FlagDockerfileImageAdds
noneDockerfile.opencodeopencode-agentPHP, Python, Perl, bash
--erlangDockerfile.erlang.opencodeopencode-agent-erlangerl, erlc, escript
--rubyDockerfile.ruby.opencodeopencode-agent-rubyruby, irb, gem, erb

A task on the Erlang pages, and a shell in the Ruby image:

container/tools/ai/opencode-docker.sh --erlang ../site-agent run "<task>"

container/tools/ai/opencode-docker.sh --ruby ../site-agent shell

Add a flag for another language

A flag is one line in the option loop at the top of the script: it names the Dockerfile and the image.

Tested with Go in a throwaway worktree.

First the Dockerfile of the language (the image page, example: Go), then the line, after the one of --ruby:

vi container/tools/ai/opencode-docker.sh

--go) dockerfile=Dockerfile.go.opencode; image=opencode-agent-go; shift ;;

That line is all the script needs. In the test it was the whole change:

git diff --stat

container/tools/ai/opencode-docker.sh | 1 + 1 file changed, 1 insertion(+)

Then the flag works like the others:

container/tools/ai/opencode-docker.sh --go ../site-agent shell

go version

go version go1.26.10 linux/amd64

Keys for one run

--openrouter-key and --opencode-key read a key from a file outside the repo and give it to this one run as an environment variable: OPENROUTER_API_KEY for OpenRouter, OPENCODE_API_KEY for OpenCode Zen and OpenCode Go.

The OpenCode key comes from the OpenCode console; save it the way the OpenRouter key is saved (a key for the container), as ~/.config/opencode-agent/opencode.key. Both options together work. Tested with dummy keys, in the shell:

container/tools/ai/opencode-docker.sh --openrouter-key ~/.config/opencode-agent/openrouter.key --opencode-key ~/.config/opencode-agent/opencode.key ../site-agent shell

At the /work $ prompt:

opencode auth list

OpenCode Go OPENCODE_API_KEY environment OpenCode Console OPENCODE_API_KEY environment OpenRouter OPENROUTER_API_KEY environment

opencode models | grep '^opencode-go/deepseek'

opencode-go/deepseek-v4-pro opencode-go/deepseek-v4.1-flash

A run on DeepSeek V4.1 Flash through OpenCode Go names the model with -m:

container/tools/ai/opencode-docker.sh --opencode-key ~/.config/opencode-agent/opencode.key ../site-agent run -m opencode-go/deepseek-v4.1-flash "<task>"

Article author: Klavdiya Kondakova

Banner image