Skip to content
Merged
100 changes: 51 additions & 49 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,46 @@
# lives under ~/workspace. Node lives in /opt/n (outside home) so it's never
# shadowed when a volume mounts over the home directory.
#
# Multi-stage: the `builder` stage fetches relocatable toolchains (Node via `n`,
# the opencode binary) so installers and caches never land in the final image.
# The final stage carries only the runtime: apt dev packages + copied-in Node + opencode.
# Three-stage build:
# base — apt packages, user, sudo, init — shared by builder and final
# builder — fetches relocatable toolchains (Node, opencode, Homebrew)
# final — copies in runtimes from builder; carries only runtime layers

ARG OPENCODE_VERSION=0.0.0
# Node lives OUTSIDE /home/opencode so it's never shadowed by a volume mount
# over the entire home directory. /opt/n stays on the ephemeral image rootfs.
ARG NODE_PREFIX=/opt/n

# ---------------------------------------------------------------------------
# builder: fetch Node (via n) + the opencode binary
# base: common runtime layer (apt, user, sudo, init)
# ---------------------------------------------------------------------------
FROM ubuntu:26.04 AS builder
FROM ubuntu:26.04 AS base

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl xz-utils bash libatomic1 \
ca-certificates curl wget git openssh-client unzip xz-utils \
build-essential pkg-config \
python3 python3-pip python3-venv ruby \
ripgrep fd-find jq less nano vim-tiny \
sudo tini open-iscsi tzdata locales \
&& rm -rf /var/lib/apt/lists/*

RUN userdel --remove ubuntu 2>/dev/null || true; \
groupdel ubuntu 2>/dev/null || true; \
groupadd --gid 1000 opencode \
&& useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash opencode

RUN echo 'opencode ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/opencode \
&& chmod 0440 /etc/sudoers.d/opencode \
&& visudo -cf /etc/sudoers.d/opencode

COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 0755 /usr/local/bin/entrypoint.sh

# ---------------------------------------------------------------------------
# builder: fetch Node, opencode, and Homebrew
# ---------------------------------------------------------------------------
FROM base AS builder

ARG NODE_PREFIX
ENV N_PREFIX=${NODE_PREFIX}
ENV PATH=${NODE_PREFIX}/bin:${PATH}
Expand All @@ -41,63 +63,43 @@ RUN curl -fsSL https://opencode.ai/install | VERSION="${OPENCODE_VERSION}" bash
&& chmod 0755 /opt/opencode \
&& /opt/opencode --version

RUN mkdir -p /home/linuxbrew \
&& chown opencode:opencode /home/linuxbrew \
&& sudo -u opencode NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" \
&& sudo -u opencode /home/linuxbrew/.linuxbrew/bin/brew cleanup --prune=all \
&& sudo -u opencode rm -rf "$(sudo -u opencode /home/linuxbrew/.linuxbrew/bin/brew --cache)" \
&& rm -rf /home/linuxbrew/.linuxbrew/Homebrew/Library/Taps/homebrew/homebrew-core \
&& rm -rf /home/linuxbrew/.linuxbrew/Homebrew/Library/Homebrew/test \
&& rm -rf /home/linuxbrew/.linuxbrew/Homebrew/Library/Homebrew/cask \
&& rm -rf /home/linuxbrew/.linuxbrew/Homebrew/Library/Homebrew/vendor/bundle/ruby/*/cache \
&& rm -rf /home/linuxbrew/.linuxbrew/Homebrew/Library/Homebrew/vendor/bundle/ruby/*/doc \
&& rm -rf /home/linuxbrew/.linuxbrew/Homebrew/Library/Homebrew/vendor/portable-ruby \
&& rm -rf /home/linuxbrew/.linuxbrew/share/man \
&& rm -rf /home/linuxbrew/.linuxbrew/share/doc \
&& rm -rf /home/linuxbrew/.linuxbrew/share/zsh

# ---------------------------------------------------------------------------
# final: the sandbox runtime
# final: runtime image
# ---------------------------------------------------------------------------
FROM ubuntu:26.04
FROM base

ENV DEBIAN_FRONTEND=noninteractive

# General-purpose toolchain: VCS, build tools, common languages + CLI utilities.
# `sudo` lets the sandbox user install system packages at runtime.
# `build-essential`/`pkg-config` support native npm addons and pip source builds.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl wget git openssh-client unzip xz-utils \
build-essential pkg-config \
python3 python3-pip python3-venv \
ripgrep fd-find jq less nano vim-tiny \
sudo \
tini \
open-iscsi \
tzdata locales \
&& rm -rf /var/lib/apt/lists/*

# Unprivileged runtime user; uid/gid 1000.
RUN userdel --remove ubuntu 2>/dev/null || true; \
groupdel ubuntu 2>/dev/null || true; \
groupadd --gid 1000 opencode \
&& useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash opencode

# Passwordless sudo. The root filesystem is ephemeral — apt-installed packages
# are lost on container restart; only /home/opencode (the persistent mount) keeps data.
RUN echo 'opencode ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/opencode \
&& chmod 0440 /etc/sudoers.d/opencode \
&& visudo -cf /etc/sudoers.d/opencode

# `n` CLI for runtime Node version switches.
RUN curl -fsSL -o /usr/local/bin/n https://raw.githubusercontent.com/tj/n/master/bin/n \
&& chmod 0755 /usr/local/bin/n
ARG NODE_PREFIX
ENV N_PREFIX=${NODE_PREFIX}
ENV PATH=${N_PREFIX}/bin:/home/opencode/.local/bin:${PATH}
ENV PATH=${N_PREFIX}/bin:/home/opencode/.local/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:${PATH}

# Toolchains from builder (no installer residue).
COPY --from=builder --chown=opencode:opencode ${NODE_PREFIX} ${NODE_PREFIX}
COPY --from=builder /opt/opencode /usr/local/bin/opencode
COPY --from=builder --chown=opencode:opencode /home/linuxbrew /home/linuxbrew

RUN node --version && npm --version && opencode --version

# Ensure login shells pick up NODE_PREFIX on PATH.
RUN printf 'export N_PREFIX=%s\nfor d in "$N_PREFIX/bin" "$HOME/.local/bin"; do case ":$PATH:" in *":$d:"*) ;; *) PATH="$d:$PATH";; esac; done\nexport PATH\n' "${N_PREFIX}" > /etc/profile.d/node-path.sh \
RUN printf 'export N_PREFIX=%s\nfor d in "$N_PREFIX/bin" "$HOME/.local/bin" "/home/linuxbrew/.linuxbrew/bin" "/home/linuxbrew/.linuxbrew/sbin"; do case ":$PATH:" in *":$d:"*) ;; *) PATH="$d:$PATH";; esac; done\nexport PATH\n' "${N_PREFIX}" > /etc/profile.d/node-path.sh \
&& chmod 0644 /etc/profile.d/node-path.sh

COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 0755 /usr/local/bin/entrypoint.sh

USER opencode
ENV HOME=/home/opencode
WORKDIR /home/opencode/workspace

EXPOSE 4096

# tini as PID 1 for zombie reaping and clean signal forwarding.
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ A general-purpose Ubuntu Docker image for running [opencode](https://opencode.ai
| **Node.js** | Current LTS via `n`, installed to `/opt/n` (outside home) |
| **Python 3** | pip, venv |
| **Build tools** | `build-essential`, `pkg-config` (for native npm addons, pip source builds) |
| **Homebrew** | Linux-native Homebrew (`/home/linuxbrew/.linuxbrew`) — `brew` on PATH for all users |
| **CLI utilities** | git, curl, wget, jq, ripgrep, fd-find, vim, nano, less, unzip, ssh client |
| **Init** | tini as PID 1 (zombie reaping, clean shutdown) |

Expand Down Expand Up @@ -82,3 +83,4 @@ Fetches the latest release from [anomalyco/opencode](https://github.com/anomalyc
- The root filesystem is ephemeral; mount `/home/opencode` as the persistent volume for all user data (dotfiles, config, projects). The `~/workspace` subdirectory is the default workdir.
- `~/.local/bin` is on PATH and user-writable, useful for dropping custom tools at runtime.
- Node version can be switched at runtime with `n <version>` (e.g. `n lts`).
- Homebrew is installed under `/home/linuxbrew/.linuxbrew` (outside the persistent volume) and is usable immediately by the `opencode` user.
Loading