Compare commits

..

8 Commits

7 changed files with 256 additions and 91 deletions
+16
View File
@@ -41,3 +41,19 @@ jobs:
tags: | tags: |
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }} ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest
- name: Update Docker Hub description
run: |
TOKEN=$(curl -s -X POST https://hub.docker.com/v2/users/login/ \
-H "Content-Type: application/json" \
-d '{"username":"${{ vars.DOCKERHUB_USERNAME }}","password":"${{ secrets.DOCKERHUB_TOKEN }}"}' \
| jq -r .token)
jq -n \
--arg full "$(cat DOCKER_HUB.md)" \
--arg short "Portable AI dev environment for opencode. Debian-based with git, Node.js, AWS CLI, and SSH support." \
'{"full_description": $full, "description": $short}' | \
curl -s -o /dev/null -w "%{http_code}" -X PATCH \
"https://hub.docker.com/v2/repositories/${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox/" \
-H "Authorization: JWT $TOKEN" \
-H "Content-Type: application/json" \
-d @-
+89 -8
View File
@@ -32,6 +32,37 @@ docker run -it --rm \
Then run `opencode` when ready. Then run `opencode` when ready.
## Running Multiple Shells
Once opencode is running it takes over the terminal. To have a separate shell for `aws`, `git`, or other commands, run the container in the background and attach multiple times:
```bash
# Start in background
docker run -d --name devbox \
-e ANTHROPIC_API_KEY=your-key \
-e OPENCODE_PROVIDER=anthropic \
-v ~/projects:/workspace \
-v ~/.ssh:/home/developer/.ssh:ro \
joakimp/opencode-devbox:latest sleep infinity
# Shell 1: run opencode
docker exec -it devbox opencode
# Shell 2 (separate terminal): aws, git, etc.
docker exec -it devbox bash
# When done
docker rm -f devbox
```
With docker-compose this is simpler:
```bash
docker compose up -d
docker compose exec devbox opencode # terminal 1
docker compose exec devbox bash # terminal 2
```
## Environment Variables ## Environment Variables
### Provider Configuration ### Provider Configuration
@@ -72,18 +103,47 @@ opencode
| `GIT_USER_NAME` | Git commit author name | | `GIT_USER_NAME` | Git commit author name |
| `GIT_USER_EMAIL` | Git commit author email | | `GIT_USER_EMAIL` | Git commit author email |
## Volumes ### User ID Mapping
| Host Path | Container Path | Purpose | The container runs as user `developer` (UID 1000 by default). If your host user has a different UID, file permission mismatches can occur on mounted volumes.
The entrypoint automatically detects the owner of `/workspace` and adjusts the container user's UID/GID to match. You can also set it explicitly:
| Variable | Description | Default |
|---|---|---| |---|---|---|
| Your project directory | `/workspace` | Code you want to work on | | `USER_UID` | Container user UID | Auto-detect from `/workspace` owner |
| `~/.ssh` | `/home/developer/.ssh:ro` | SSH keys for git (read-only) | | `USER_GID` | Container user GID | Auto-detect from `/workspace` owner |
| (optional) `~/.aws` | `/home/developer/.aws:ro` | AWS credentials/config |
| (optional) Custom config | `/home/developer/.config/opencode/opencode.json:ro` | Full opencode config with MCP servers, etc. | ```bash
docker run -it --rm \
-e USER_UID=$(id -u) \
-e USER_GID=$(id -g) \
-v ~/projects:/workspace \
joakimp/opencode-devbox:latest
```
## Data Storage and Persistence
Understanding what survives container restarts and what doesn't:
| Path in container | Source | Survives restart? | Contains |
|---|---|---|---|
| `/workspace` | Host bind mount | ✅ Yes — lives on host | Your project files |
| `/home/developer/.ssh` | Host bind mount (ro) | ✅ Yes — lives on host | SSH keys |
| `/home/developer/.local/share/opencode` | Named volume (if configured) | ✅ Yes — Docker volume | Session history, memory, auth tokens |
| `/home/developer/.config/opencode/opencode.json` | Generated by entrypoint | ❌ No — regenerated each start | Provider config, MCP server definitions |
| `/home/developer/.aws` | Host bind mount (if configured) | ✅ Yes — lives on host | AWS credentials/SSO cache |
### Key points
- **Project files** (`/workspace`) are always safe — they're your host filesystem.
- **opencode config** is auto-generated from `OPENCODE_PROVIDER` env var on each start. It only sets provider and model — no MCP servers. To persist MCP server config, mount your own config file (see Custom opencode Config below).
- **opencode data** (session history, memory) is lost with `--rm` unless you add a named volume.
- **AWS SSO tokens** are stored inside the container and lost on restart. Re-run `aws sso login` after restarting.
### Persisting opencode data ### Persisting opencode data
To keep opencode state (session history, memory) between runs, add a named volume: Add a named volume to keep session history and memory between runs:
```bash ```bash
docker run -it --rm \ docker run -it --rm \
@@ -107,7 +167,21 @@ When a config file is mounted, the `OPENCODE_PROVIDER` auto-config is skipped.
## Using docker-compose ## Using docker-compose
Create a `docker-compose.yml`: Create a `docker-compose.yml` and a `.env` file in the same directory:
```bash
mkdir opencode-devbox && cd opencode-devbox
```
`.env` — your secrets and settings (never commit this):
```bash
ANTHROPIC_API_KEY=sk-ant-...
GIT_USER_NAME=Your Name
GIT_USER_EMAIL=you@example.com
```
`docker-compose.yml`:
```yaml ```yaml
services: services:
@@ -125,11 +199,18 @@ services:
- ~/projects:/workspace - ~/projects:/workspace
- ~/.ssh:/home/developer/.ssh:ro - ~/.ssh:/home/developer/.ssh:ro
- devbox-data:/home/developer/.local/share/opencode - devbox-data:/home/developer/.local/share/opencode
# Optional: mount your own opencode config (MCP servers, custom models, etc.)
# - ./opencode.json:/home/developer/.config/opencode/opencode.json:ro
# Optional: mount opencode skills from host
# - ~/.config/opencode/skills:/home/developer/.config/opencode/skills:ro
# - ~/.agents/skills:/home/developer/.agents/skills:ro
volumes: volumes:
devbox-data: devbox-data:
``` ```
Docker Compose automatically loads `.env` from the same directory as the compose file. The `${VAR}` references are substituted with values from `.env`.
Then: Then:
```bash ```bash
+7 -4
View File
@@ -5,7 +5,7 @@ ARG DEBIAN_VERSION=bookworm-slim
FROM debian:${DEBIAN_VERSION} AS base FROM debian:${DEBIAN_VERSION} AS base
ARG TARGETARCH ARG TARGETARCH
ARG OPENCODE_VERSION=1.4.2 ARG OPENCODE_VERSION=1.4.3
LABEL maintainer="joakimp" LABEL maintainer="joakimp"
LABEL description="Portable opencode developer container" LABEL description="Portable opencode developer container"
@@ -31,6 +31,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
less \ less \
vim-tiny \ vim-tiny \
sudo \ sudo \
gosu \
locales \ locales \
procps \ procps \
unzip \ unzip \
@@ -95,16 +96,18 @@ RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
# Create standard directories # Create standard directories
RUN mkdir -p /workspace \ RUN mkdir -p /workspace \
/home/${USER_NAME}/.config/opencode \ /home/${USER_NAME}/.config/opencode/skills \
/home/${USER_NAME}/.agents/skills \
/home/${USER_NAME}/.local/share/opencode \ /home/${USER_NAME}/.local/share/opencode \
/home/${USER_NAME}/.ssh && \ /home/${USER_NAME}/.ssh && \
chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME} chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME}
# ── Entrypoint ──────────────────────────────────────────────────────── # ── Entrypoint ────────────────────────────────────────────────────────
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh
USER ${USER_NAME} # Start as root — entrypoint adjusts UID/GID then drops to developer
WORKDIR /workspace WORKDIR /workspace
ENTRYPOINT ["entrypoint.sh"] ENTRYPOINT ["entrypoint.sh"]
+26
View File
@@ -99,6 +99,8 @@ docker compose exec devbox aws --version
| `GIT_USER_EMAIL` | Git commit author email | — | | `GIT_USER_EMAIL` | Git commit author email | — |
| `WORKSPACE_PATH` | Host path to mount | `.` | | `WORKSPACE_PATH` | Host path to mount | `.` |
| `SSH_KEY_PATH` | Host SSH key directory | `~/.ssh` | | `SSH_KEY_PATH` | Host SSH key directory | `~/.ssh` |
| `USER_UID` | Override container user UID | Auto-detect from `/workspace` |
| `USER_GID` | Override container user GID | Auto-detect from `/workspace` |
### Custom opencode config ### Custom opencode config
@@ -109,6 +111,16 @@ volumes:
- ./my-opencode.json:/home/developer/.config/opencode/opencode.json:ro - ./my-opencode.json:/home/developer/.config/opencode/opencode.json:ro
``` ```
### Custom skills
Mount your host's opencode skills into the container:
```yaml
volumes:
- ~/.config/opencode/skills:/home/developer/.config/opencode/skills:ro
- ~/.agents/skills:/home/developer/.agents/skills:ro
```
### Rebuilding the Image ### Rebuilding the Image
`docker compose run` and `docker compose up` use the existing image — they **do not rebuild** when you change the Dockerfile or build args (e.g. updating `OPENCODE_VERSION`). Rebuild explicitly: `docker compose run` and `docker compose up` use the existing image — they **do not rebuild** when you change the Dockerfile or build args (e.g. updating `OPENCODE_VERSION`). Rebuild explicitly:
@@ -197,6 +209,20 @@ Container (Debian bookworm)
└── /workspace ← your code lives here └── /workspace ← your code lives here
``` ```
### Data persistence
| Path in container | Source | Survives `--rm`? | Contains |
|---|---|---|---|
| `/workspace` | Host bind mount | ✅ Yes | Your project files |
| `/home/developer/.ssh` | Host bind mount (ro) | ✅ Yes | SSH keys |
| `/home/developer/.local/share/opencode` | Named volume `devbox-data` | ✅ Yes | Session history, memory |
| `/home/developer/.config/opencode/opencode.json` | Generated by entrypoint | ❌ No | Provider/model config |
| `/home/developer/.aws` | Not mounted by default | ❌ No | AWS SSO tokens |
**opencode config** (`opencode.json`) is auto-generated from `OPENCODE_PROVIDER` on each start. It sets provider and model only — no MCP servers. To use MCP servers or custom settings, mount your own config file (see Custom opencode config above).
To persist AWS SSO sessions across restarts, uncomment the `~/.aws` volume mount in `docker-compose.yml`.
## License ## License
MIT MIT
+6 -2
View File
@@ -30,8 +30,12 @@ services:
# SSH keys (read-only) — for git push/pull # SSH keys (read-only) — for git push/pull
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro - ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
# Optional: mount your own opencode config # Optional: mount your own opencode config (MCP servers, custom models, etc.)
# - ./config/opencode.json:/home/developer/.config/opencode/opencode.json:ro # - ./opencode.json:/home/developer/.config/opencode/opencode.json:ro
# Optional: mount opencode skills from host
# - ~/.config/opencode/skills:/home/developer/.config/opencode/skills:ro
# - ~/.agents/skills:/home/developer/.agents/skills:ro
# Optional: persist opencode data (auth, memory, etc.) # Optional: persist opencode data (auth, memory, etc.)
- devbox-data:/home/developer/.local/share/opencode - devbox-data:/home/developer/.local/share/opencode
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
# ── Git config defaults ──────────────────────────────────────────────
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
git config --global user.name "$GIT_USER_NAME"
fi
if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null; then
git config --global user.email "$GIT_USER_EMAIL"
fi
# ── Generate opencode config from env vars if no config mounted ──────
CONFIG_DIR="$HOME/.config/opencode"
CONFIG_FILE="$CONFIG_DIR/opencode.json"
if [ ! -f "$CONFIG_FILE" ] && [ -n "${OPENCODE_PROVIDER:-}" ]; then
echo "Generating opencode config for provider: $OPENCODE_PROVIDER"
mkdir -p "$CONFIG_DIR"
case "$OPENCODE_PROVIDER" in
anthropic)
cat > "$CONFIG_FILE" <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"model": "${OPENCODE_MODEL:-anthropic/claude-sonnet-4-5}",
"share": "disabled",
"autoupdate": false
}
EOF
;;
openai)
cat > "$CONFIG_FILE" <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"model": "${OPENCODE_MODEL:-openai/gpt-4o}",
"share": "disabled",
"autoupdate": false
}
EOF
;;
amazon-bedrock)
cat > "$CONFIG_FILE" <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"model": "${OPENCODE_MODEL:-amazon-bedrock/anthropic.claude-sonnet-4-5-v1}",
"share": "disabled",
"autoupdate": false,
"provider": {
"amazon-bedrock": {
"options": {
"region": "${AWS_REGION:-us-east-1}"
}
}
}
}
EOF
;;
*)
cat > "$CONFIG_FILE" <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"model": "${OPENCODE_MODEL:-anthropic/claude-sonnet-4-5}",
"share": "disabled",
"autoupdate": false
}
EOF
;;
esac
fi
# ── Execute command ──────────────────────────────────────────────────
exec "$@"
+40 -77
View File
@@ -1,84 +1,47 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
USER_NAME="developer"
CURRENT_UID=$(id -u "$USER_NAME")
CURRENT_GID=$(id -g "$USER_NAME")
# ── UID/GID adjustment ───────────────────────────────────────────────
# Priority: env vars > auto-detect from /workspace > default (1000)
TARGET_UID="${USER_UID:-}"
TARGET_GID="${USER_GID:-}"
# Auto-detect from /workspace owner if env vars not set
if [ -z "$TARGET_UID" ] && [ -d /workspace ]; then
WORKSPACE_UID=$(stat -c '%u' /workspace 2>/dev/null || stat -f '%u' /workspace 2>/dev/null)
WORKSPACE_GID=$(stat -c '%g' /workspace 2>/dev/null || stat -f '%g' /workspace 2>/dev/null)
# Only adjust if workspace is owned by a non-root user
if [ "$WORKSPACE_UID" != "0" ] && [ "$WORKSPACE_UID" != "$CURRENT_UID" ]; then
TARGET_UID="$WORKSPACE_UID"
TARGET_GID="${TARGET_GID:-$WORKSPACE_GID}"
fi
fi
# Apply UID/GID changes if needed
if [ -n "$TARGET_GID" ] && [ "$TARGET_GID" != "$CURRENT_GID" ]; then
groupmod -g "$TARGET_GID" "$USER_NAME" 2>/dev/null || true
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -group "$CURRENT_GID" -exec chgrp "$TARGET_GID" {} + 2>/dev/null || true
fi
if [ -n "$TARGET_UID" ] && [ "$TARGET_UID" != "$CURRENT_UID" ]; then
usermod -u "$TARGET_UID" "$USER_NAME" 2>/dev/null || true
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -user "$CURRENT_UID" -exec chown "$TARGET_UID" {} + 2>/dev/null || true
echo "Adjusted developer UID:GID to $TARGET_UID:${TARGET_GID:-$CURRENT_GID}"
fi
# ── SSH key permissions ────────────────────────────────────────────── # ── SSH key permissions ──────────────────────────────────────────────
# If SSH keys are mounted, fix permissions (bind mounts may have wrong perms) # If SSH keys are mounted, fix permissions (bind mounts may have wrong perms)
if [ -d "$HOME/.ssh" ] && [ "$(ls -A "$HOME/.ssh" 2>/dev/null)" ]; then if [ -d "/home/$USER_NAME/.ssh" ] && [ "$(ls -A "/home/$USER_NAME/.ssh" 2>/dev/null)" ]; then
chmod 700 "$HOME/.ssh" chmod 700 "/home/$USER_NAME/.ssh"
find "$HOME/.ssh" -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; 2>/dev/null || true find "/home/$USER_NAME/.ssh" -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; 2>/dev/null || true
find "$HOME/.ssh" -type f -name "*.pub" -exec chmod 644 {} \; 2>/dev/null || true find "/home/$USER_NAME/.ssh" -type f -name "*.pub" -exec chmod 644 {} \; 2>/dev/null || true
[ -f "$HOME/.ssh/known_hosts" ] && chmod 644 "$HOME/.ssh/known_hosts" [ -f "/home/$USER_NAME/.ssh/known_hosts" ] && chmod 644 "/home/$USER_NAME/.ssh/known_hosts"
[ -f "$HOME/.ssh/config" ] && chmod 600 "$HOME/.ssh/config" [ -f "/home/$USER_NAME/.ssh/config" ] && chmod 600 "/home/$USER_NAME/.ssh/config"
fi fi
# ── Git config defaults ────────────────────────────────────────────── # ── Drop to developer user for remaining setup ──────────────────────
# Set git config from env vars if not already configured via mounted .gitconfig exec gosu "$USER_NAME" /usr/local/bin/entrypoint-user.sh "$@"
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
git config --global user.name "$GIT_USER_NAME"
fi
if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null; then
git config --global user.email "$GIT_USER_EMAIL"
fi
# ── Generate opencode config from env vars if no config mounted ──────
CONFIG_DIR="$HOME/.config/opencode"
CONFIG_FILE="$CONFIG_DIR/opencode.json"
if [ ! -f "$CONFIG_FILE" ] && [ -n "${OPENCODE_PROVIDER:-}" ]; then
echo "Generating opencode config for provider: $OPENCODE_PROVIDER"
mkdir -p "$CONFIG_DIR"
# Build provider-specific config
case "$OPENCODE_PROVIDER" in
anthropic)
cat > "$CONFIG_FILE" <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"model": "${OPENCODE_MODEL:-anthropic/claude-sonnet-4-5}",
"share": "disabled",
"autoupdate": false
}
EOF
;;
openai)
cat > "$CONFIG_FILE" <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"model": "${OPENCODE_MODEL:-openai/gpt-4o}",
"share": "disabled",
"autoupdate": false
}
EOF
;;
amazon-bedrock)
cat > "$CONFIG_FILE" <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"model": "${OPENCODE_MODEL:-amazon-bedrock/anthropic.claude-sonnet-4-5-v1}",
"share": "disabled",
"autoupdate": false,
"provider": {
"amazon-bedrock": {
"options": {
"region": "${AWS_REGION:-us-east-1}"
}
}
}
}
EOF
;;
*)
cat > "$CONFIG_FILE" <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"model": "${OPENCODE_MODEL:-anthropic/claude-sonnet-4-5}",
"share": "disabled",
"autoupdate": false
}
EOF
;;
esac
fi
# ── Execute command ──────────────────────────────────────────────────
exec "$@"