The image of OpenCode in Docker: PHP by default, Erlang, Ruby, Go and other languages
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 3 (the
image) of the manual in full.
It explains why the official OpenCode image is not enough, and shows the
default image, which adds PHP and other
programs.
Then it shows two more images: one with
Erlang and one with
Ruby.
The launcher uses them when you start a run with
--erlang or --ruby.
At the end you learn how to make an image for another language.
All Dockerfiles are in the folder
container/containerfiles.
Why your own image
The image pulled in step 1 (Docker and the OpenCode image) runs OpenCode, but not the checks of this project. It runs as root and has none of the programs the skills call:docker run --rm --entrypoint sh ghcr.io/anomalyco/opencode:2.1.4 -c 'id; php -v; bash --version; python3 --version; perl -v'
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video) sh: php: not found sh: bash: not found sh: python3: not found sh: perl: not found
And what it writes into a mounted folder belongs to root on the host. From an empty folder:
docker run --rm -v "$PWD:/work" --entrypoint sh ghcr.io/anomalyco/opencode:2.1.4 -c 'touch /work/page.php'
ls -l page.php
-rw-r--r-- 1 root root 0 Oct 26 23:49 page.php
echo '<?php' >> page.php
bash: page.php: Permission denied
The default image
So you build your own image on top of it.FROM takes the pulled image, and the lines after it add the programs and a user with your UID:
vi container/containerfiles/Dockerfile.opencode
# OpenCode for --auto runs on an agent worktree. The official image, plus # PHP for php -l and page renders, bash, Python, Perl and the GNU tools for # the scripts in container/tools, and a user with your UID, so the files the # agent writes stay yours. FROM ghcr.io/anomalyco/opencode:2.1.4 RUN apk add --no-cache bash python3 perl grep sed findutils coreutils \ php83 php83-ctype php83-dom php83-iconv php83-mbstring php83-session \ php83-simplexml php83-tokenizer php83-xml \ && ln -s /usr/bin/php83 /usr/local/bin/php ARG HOST_UID=1000 ARG HOST_GID=1000 RUN addgroup -g ${HOST_GID} agent \ && adduser -D -u ${HOST_UID} -G agent agent \ && mkdir -p /work && chown agent:agent /work USER agent # Created by the user, so a new named volume mounted here is writable. RUN mkdir -p /home/agent/.local/share/opencode WORKDIR /work
- The official image is Alpine Linux with OpenCode, grep and ripgrep, so packages come from apk.
- What the tools of the repo call. PHP 8.3 with the extensions the pages need: without php83-ctype every page stopped with Call to undefined function ctype_digit(). Bash, Python and Perl for the scripts. The GNU versions of grep, sed, find and coreutils, because the BusyBox versions lack options the scripts use.
- A pinned version. The tag is what opencode --version prints on the host. Update both together.
- A user with your UID. The files the agent writes belong to you, and the agent cannot install packages.
- No git on purpose. Commits happen on the host, after the review and merge on the host.
Build and check it
You do not have to build it by hand: the launcher script (page
The launcher) runs
docker build at every start, so a change to the
Dockerfile takes effect at the
next run.
The first build took 33 seconds, later ones come from the cache. To
build it once yourself and compare:
docker build -t opencode-agent --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -f container/containerfiles/Dockerfile.opencode container/containerfiles
docker run --rm --entrypoint sh opencode-agent -c 'id; php -v | head -1; bash --version | head -1; python3 --version; perl -v | sed -n 2p'
uid=1000(agent) gid=1000(agent) groups=1000(agent) PHP 8.3.36 (cli) (built: Oct 21 2026 16:38:20) (NTS) GNU bash, version 5.3.9(1)-release (x86_64-alpine-linux-musl) Python 3.14.8 This is perl 5, version 42, subversion 2 (v5.42.2) built for x86_64-linux-thread-multi
The image is 354 MB, 94 MB more than the official one.
The image with Erlang
A task that checks the Erlang pages has to
run their examples, and the default image has no
erl.
The Erlang image is the default file with one more package at the end of
the apk line, erlang,
and a first comment that says so:
vi container/containerfiles/Dockerfile.erlang.opencode
# The image of Dockerfile.opencode plus Erlang/OTP (erl, erlc, escript), so # the agent can run the examples of the Erlang pages. OpenCode for --auto # runs on an agent worktree. The official image, plus PHP for php -l and page # renders, bash, Python, Perl and the GNU tools for the scripts in # container/tools, and a user with your UID, so the files the agent writes # stay yours. FROM ghcr.io/anomalyco/opencode:2.1.4 RUN apk add --no-cache bash python3 perl grep sed findutils coreutils \ php83 php83-ctype php83-dom php83-iconv php83-mbstring php83-session \ php83-simplexml php83-tokenizer php83-xml \ erlang \ && ln -s /usr/bin/php83 /usr/local/bin/php ARG HOST_UID=1000 ARG HOST_GID=1000 RUN addgroup -g ${HOST_GID} agent \ && adduser -D -u ${HOST_UID} -G agent agent \ && mkdir -p /work && chown agent:agent /work USER agent # Created by the user, so a new named volume mounted here is writable. RUN mkdir -p /home/agent/.local/share/opencode WORKDIR /work
Compared with the default file, only the comment and that line differ:
diff container/containerfiles/Dockerfile.opencode container/containerfiles/Dockerfile.erlang.opencode
1,4c1,6 < # OpenCode for --auto runs on an agent worktree. The official image, plus < # PHP for php -l and page renders, bash, Python, Perl and the GNU tools for < # the scripts in container/tools, and a user with your UID, so the files the < # agent writes stay yours. --- > # The image of Dockerfile.opencode plus Erlang/OTP (erl, erlc, escript), so > # the agent can run the examples of the Erlang pages. OpenCode for --auto > # runs on an agent worktree. The official image, plus PHP for php -l and page > # renders, bash, Python, Perl and the GNU tools for the scripts in > # container/tools, and a user with your UID, so the files the agent writes > # stay yours. 8a11 > erlang \
The launcher builds it as opencode-agent-erlang
when a run starts with --erlang
(the image per run:
--erlang and --ruby).
A shell in it, on the worktree of
step 2 (a worktree for the
agent):
container/tools/ai/opencode-docker.sh --erlang ../site-agent shell
At the /work $ prompt, the release:
erl -noshell -eval 'io:format("~s~n", [erlang:system_info(otp_release)]), halt().'
27
A module compiled with erlc and run with erl, in /tmp, where scratch files belong:
cd /tmp
vi hello.erl
-module(hello). -export([hi/0]). hi() -> io:format("hi from a module~n").
erlc hello.erl
erl -noshell -s hello hi -s init stop
hi from a module
And a script for escript:
vi s.escript
#!/usr/bin/env escript main(_) -> io:format("hi from escript~n").
escript s.escript
hi from escript
The image is 435 MB, 81 MB more than the default one.
The package erlang of Alpine 3.24 is Erlang/OTP
27; erlang28 would give OTP 28
(another language).
The image with Ruby
The same for the Ruby pages: the default file plus the package ruby, which brings irb, gem and erb along:
vi container/containerfiles/Dockerfile.ruby.opencode
# The image of Dockerfile.opencode plus Ruby (ruby, irb, gem, erb), so the # agent can run the examples of the Ruby pages. OpenCode for --auto runs on # an agent worktree. The official image, plus PHP for php -l and page # renders, bash, Python, Perl and the GNU tools for the scripts in # container/tools, and a user with your UID, so the files the agent writes # stay yours. FROM ghcr.io/anomalyco/opencode:2.1.4 RUN apk add --no-cache bash python3 perl grep sed findutils coreutils \ php83 php83-ctype php83-dom php83-iconv php83-mbstring php83-session \ php83-simplexml php83-tokenizer php83-xml \ ruby \ && ln -s /usr/bin/php83 /usr/local/bin/php ARG HOST_UID=1000 ARG HOST_GID=1000 RUN addgroup -g ${HOST_GID} agent \ && adduser -D -u ${HOST_UID} -G agent agent \ && mkdir -p /work && chown agent:agent /work USER agent # Created by the user, so a new named volume mounted here is writable. RUN mkdir -p /home/agent/.local/share/opencode WORKDIR /work
A shell with --ruby:
container/tools/ai/opencode-docker.sh --ruby ../site-agent shell
At the /work $ prompt:
ruby -v
ruby 3.4.12 (2026-10-23 revision 366f5fdc00) +PRISM [x86_64-linux-musl]
ruby -e 'puts [3, 1, 2].sort.inspect'
[1, 2, 3]
gem -v
3.6.9
The image is 373 MB. The first start with --ruby, which built it, took 56 seconds.
Another language
An image for another language takes four steps.
1. Find the package. The packages come from
Alpine Linux, the system of the official
image.
The agent cannot install anything, so look the names up as root in a
throwaway container of the default image.
The container ends with the command, and nothing of it stays:
docker run --rm --user root --entrypoint sh opencode-agent -c 'apk update -q >/dev/null 2>&1; apk search -e go ruby erlang nodejs npm openjdk21-jdk rust cargo elixir gcc musl-dev lua5.4 dotnet9-sdk'
cargo-1.96.1-r0 dotnet9-sdk-9.0.123-r0 elixir-1.19.7-r0 erlang27-27.3.4.19-r0 erlang28-28.5.0.8-r0 gcc-15.2.0-r5 go-1.26.10-r0 lua5.4-5.4.8-r0 musl-dev-1.2.6-r3 nodejs-24.22.1-r1 nodejs-current-26.12.1-r0 npm-11.12.1-r0 openjdk21-jdk-21.0.13.0.3_p1-r0 ruby-3.4.12-r0 rust-1.96.1-r0
-e searches exact names. Each line is a package
and its version in Alpine 3.24.
erlang itself is not a line: it is a name that
erlang27 provides, and apk add
erlang installs that one. What these packages give:
| Language | Packages | Checked here |
|---|---|---|
| Erlang | erlang (OTP 27) or erlang28 | built and run: the image with Erlang |
| Ruby | ruby | built and run: the image with Ruby |
| Go | go | built and run: example: Go |
| Node.js | nodejs npm, or nodejs-current for the newer line | in the list |
| Java | openjdk21-jdk | in the list |
| Rust | rust cargo | in the list |
| Elixir | elixir | in the list |
| C | gcc musl-dev | in the list |
| Lua | lua5.4 | in the list |
| C# | dotnet9-sdk | in the list |
2. Write the Dockerfile. Copy the default file to Dockerfile.<language>.opencode, add the packages at the end of the apk add line, before && ln, and say in the first comment what the image adds.
3. Give the launcher a flag for it, one line in the script (add a flag for another language).
4. Check it in a shell of the new image, as for the image with Erlang, and note its size.
- One image per run. A task that needs two languages gets one Dockerfile with both packages.
- The versions come with the base. Alpine 3.24 is the system of the OpenCode 2.1.4 image; a new OpenCode tag may bring a new Alpine and new versions of every package.
- Everything else stays the same. The user with your UID, no root and no git: what the agent can reach does not change with the language.
Example: Go
Go, step by step. The file:
vi container/containerfiles/Dockerfile.go.opencode
# The image of Dockerfile.opencode plus Go (go, gofmt), so the agent can run # the examples of the Go pages. FROM ghcr.io/anomalyco/opencode:2.1.4 RUN apk add --no-cache bash python3 perl grep sed findutils coreutils \ php83 php83-ctype php83-dom php83-iconv php83-mbstring php83-session \ php83-simplexml php83-tokenizer php83-xml \ go \ && ln -s /usr/bin/php83 /usr/local/bin/php ARG HOST_UID=1000 ARG HOST_GID=1000 RUN addgroup -g ${HOST_GID} agent \ && adduser -D -u ${HOST_UID} -G agent agent \ && mkdir -p /work && chown agent:agent /work USER agent RUN mkdir -p /home/agent/.local/share/opencode WORKDIR /work
With the flag --go added to the launcher (add a flag for another language), a shell in it:
container/tools/ai/opencode-docker.sh --go ../site-agent shell
At the /work $ prompt:
go version
go version go1.26.10 linux/amd64
cd /tmp
vi hello.go
package main import "fmt" func main() { fmt.Println("hi from go") }
go run hello.go
hi from go
Tested in a throwaway worktree, with the file and the flag added there
only.
The first start built the image in 106 seconds, and it is 737 MB: the Go
toolchain alone is 177 MB on disk.
Images on disk
Every image the launcher has built stays until you remove it:
docker image ls 'opencode-agent*'
IMAGE ID DISK USAGE CONTENT SIZE EXTRA opencode-agent-erlang:latest d12ea898f281 435MB 0B U opencode-agent-ruby:latest f11ce3d8efce 373MB 0B opencode-agent:latest 9430341af54f 354MB 0B
U marks an image that a container uses right
now.
The disk usage of the images does not simply add up: they share the
layers of the official image. To remove one you no longer need:
docker image rm opencode-agent-ruby
The next run with its flag builds it again.
Article author: Klavdiya Kondakova