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
| Line | What 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 |
- The model line is what the launcher reads: the value after -m or --model when the call has one, else the first "model" line of the config file. No other config is mounted, so that is the model OpenCode uses. Read the section Where the model comes from of the article Select the model to learn how to check what OpenCode itself read.
- The lines go to stderr, so a redirect of stdout gets only what OpenCode prints. Tested in a shell: container/tools/ai/opencode-docker.sh ../site-agent shell -c 'echo "this is stdout"' 2>/dev/null prints this is stdout and nothing else.
- The container name is the image, the folder of the worktree and the process number of the launcher, so two runs never get the same name. Use it with docker exec and docker stop in place of a container id.
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
- The ignored-files check refuses the main checkout and any worktree in which ignored files have appeared since. Python caches pass.
- No root powers. --cap-drop ALL and no-new-privileges: the agent user has no Linux capabilities and no way to gain them.
- Read-only by default. The agent reads and runs the instructions, the skills, the docs and the tools, but cannot change its own rules or the scripts that check its work. --rw lifts that for one path and one run.
- Masked files. Every tracked file named *.env or *creds*, the names the deny rules protect on the host, reads as empty and refuses writes.
- Sessions in a volume. opencode-agent-data keeps them between runs, so --continue works. Every worktree shares it, so --continue takes the newest session of any agent (a new run does not know the last one). docker volume rm opencode-agent-data clears it.
- It says what it starts. The model and where it comes from, the image and the name of the container, to stderr (what it prints at the start).
- A key only when asked. --openrouter-key and --opencode-key pass a key to one run (keys for one run). Without them the container gets no key.
- One image per run. No flag: the default image; --erlang or --ruby: the image with that language (the image per run: --erlang and --ruby).
- Nothing else. No Docker socket, no host network, no published ports, nothing from your home folder but that key.
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 option | What it does |
|---|---|
| container/tools/ai/opencode-docker.sh ../site-agent | the 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 shell | a 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, --ruby | the 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:
| Flag | Dockerfile | Image | Adds |
|---|---|---|---|
| none | Dockerfile.opencode | opencode-agent | PHP, Python, Perl, bash |
| --erlang | Dockerfile.erlang. | opencode-agent-erlang | erl, erlc, escript |
| --ruby | Dockerfile.ruby. | opencode-agent-ruby | ruby, 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
- Built on first use. The first run with a flag builds its image: 56 seconds for Ruby here. Later runs take it from the cache. The Dockerfiles are on the page The image.
- One image per run. With both flags the last one wins. Tested: --erlang --ruby gave a shell with ruby and no erl.
- The same sessions in every image. All images mount the one sessions volume, so -s continues a session in another image (day to day usage: which image).
- A build check at every start. Even from the cache, here it took up to about 20 seconds before the container started.
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
- Name it in the usage lines too: the comment at the top of the script and the message after usage:, so [--erlang | --ruby] lists the new flag. Raise the version line when you change the script.
- The image name is lower case, as Docker requires.
- The commands that find the containers filter by image: add --filter ancestor=opencode-agent-go to them (day to day usage: which image).
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>"
- An empty key file stops the launcher before Docker starts: ERROR: <file> is empty.
- A key file in the repo or the worktree is refused, for both options: ERROR: keep the key file outside the repo and the worktree.
- A login typed into the container is different. opencode auth login in the shell stores the key in the sessions volume, and every later run sees it, with an option or without. opencode auth list shows such a login as stored, not environment (a stored login).
- Every run with a key can read it, with env. A spending limit on the key is what protects you.
Article author: Klavdiya Kondakova