Skip to content

Instantly share code, notes, and snippets.

@agrublev
Last active June 19, 2026 18:46
Show Gist options
  • Select an option

  • Save agrublev/2190bb8a3fd9bb57ce8f327413f4f432 to your computer and use it in GitHub Desktop.

Select an option

Save agrublev/2190bb8a3fd9bb57ce8f327413f4f432 to your computer and use it in GitHub Desktop.
cloud-init-gist.sh
#!/usr/bin/env bash
#
# cloud-init-gist.sh — self-contained first-boot bootstrap for a DigitalOcean
# Droplet that builds and runs the trimmed claude-universal image (Claude Code +
# Python/Node/Java). Paste this whole file into a PUBLIC GitHub gist, then set the
# droplet's user-data to a 2-line bootstrap that curls + runs it (see README at
# the bottom of this file).
#
# Design:
# - Embeds every build file inline (no git, no registry, no rsync needed).
# - Builds the image from source ON the droplet (needs 8GB RAM + swap).
# - Runs the container DETACHED and keeps it up (restart: unless-stopped) so you
# can `docker exec` into it anytime via the `claude-shell` launcher.
# - Contains NO secrets. You sign into Claude Code interactively after boot;
# the auth persists in the `claude-config` Docker volume.
# - Idempotent: re-running is a no-op once /root/.claude-setup-complete exists.
#
# SECURITY: never put ANTHROPIC_API_KEY (or any secret) in this gist — it is
# world-readable. Sign in interactively with `claude` after the droplet is up.
set -euo pipefail
REMOTE_DIR="/root/claude-universal"
MARKER="/root/.claude-setup-complete"
SWAP_GB="${SWAP_GB:-8}"
log() { echo ">>> [claude-setup] $*"; }
# --- Idempotency guard -------------------------------------------------------
if [ -f "$MARKER" ]; then
log "Already set up (found $MARKER). Nothing to do."
exit 0
fi
export DEBIAN_FRONTEND=noninteractive
# --- Patch the host OS -------------------------------------------------------
# Clears DigitalOcean's "update and reboot" notice. full-upgrade (not plain
# upgrade) so held-back kernel/lib updates actually install. No reboot here:
# this is first boot, and the container does not depend on the new kernel.
log "Patching host OS (apt-get update && full-upgrade)..."
apt-get -o DPkg::Lock::Timeout=300 update
apt-get -y -o DPkg::Lock::Timeout=300 -o Dpkg::Options::=--force-confold full-upgrade
apt-get -y -o DPkg::Lock::Timeout=300 autoremove --purge
apt-get clean
/usr/lib/update-notifier/update-motd-updates-available --force >/dev/null 2>&1 || true
# --- Ensure swap -------------------------------------------------------------
# DO droplets ship swapless; the from-source compiles can spike past RAM and get
# OOM-killed, silently aborting the build. Add swap as a guard.
if [ "$SWAP_GB" != "0" ] && ! swapon --show | grep -q /swapfile; then
log "Adding ${SWAP_GB}G swap..."
fallocate -l "${SWAP_GB}G" /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=$((SWAP_GB * 1024))
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi
# --- Ensure Docker + Compose -------------------------------------------------
if ! command -v docker >/dev/null 2>&1; then
log "Installing Docker..."
apt-get -o DPkg::Lock::Timeout=300 install -y docker.io
fi
if ! docker compose version >/dev/null 2>&1; then
log "Installing docker compose plugin..."
apt-get -o DPkg::Lock::Timeout=300 install -y docker-compose-plugin
fi
systemctl enable --now docker >/dev/null 2>&1 || true
# --- Write the build context -------------------------------------------------
log "Writing build files to ${REMOTE_DIR}..."
mkdir -p "${REMOTE_DIR}/opt"
cd "${REMOTE_DIR}"
cat > Dockerfile <<'DOCKERFILE_EOF'
# syntax=docker/dockerfile:1.7
FROM ubuntu:24.04
ARG TARGETOS
ARG TARGETARCH
ENV LANG="C.UTF-8"
ENV HOME=/root
ENV DEBIAN_FRONTEND=noninteractive
# Cap compiler parallelism during the many from-source builds (CPython, PHP,
# Erlang, Rust). Limiting -j keeps peak RAM bounded so the build survives on
# smaller / swap-light hosts. Override with --build-arg MAKEFLAGS=-jN.
ARG MAKEFLAGS="-j2"
ENV MAKEFLAGS="${MAKEFLAGS}"
ENV CARGO_BUILD_JOBS=2
### BASE ###
# Apply outstanding security updates from the base image first so the built
# image doesn't ship packages flagged as out-of-date / vulnerable. Pinned
# package versions below may be bumped by this upgrade; that is intended.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get -y --no-install-recommends upgrade \
&& rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends \
binutils=2.42-* \
sudo=1.9.* \
build-essential=12.10* \
curl=8.5.* \
dnsutils=1:9.18.* \
fd-find=9.0.* \
gettext=0.21-* \
git=1:2.43.* \
git-lfs=3.4.* \
gnupg=2.4.* \
inotify-tools=3.22.* \
iputils-ping=3:20240117-* \
jq=1.7.* \
libbz2-dev=1.0.* \
libc6=2.39-* \
libc6-dev=2.39-* \
libcurl4-openssl-dev=8.5.* \
libffi-dev=3.4.* \
libgdbm-compat-dev=1.23-* \
libgdbm-dev=1.23-* \
liblzma-dev=5.6.* \
libncurses-dev=6.4+20240113-* \
libreadline-dev=8.2-* \
libsqlite3-dev=3.45.* \
libssl-dev=3.0.* \
make=4.3-* \
moreutils=0.69-* \
netcat-openbsd=1.226-* \
openssh-client=1:9.6p1-* \
pkg-config=1.8.* \
ripgrep=14.1.* \
rsync=3.2.* \
software-properties-common=0.99.* \
sqlite3=3.45.* \
tk-dev=8.6.* \
tzdata \
universal-ctags=5.9.* \
unzip=6.0-* \
uuid-dev=2.39.* \
wget=1.21.* \
xz-utils=5.6.* \
zip=3.0-* \
zlib1g=1:1.3.* \
zlib1g-dev=1:1.3.* \
&& rm -rf /var/lib/apt/lists/*
### MISE ###
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
install -dm 0755 /etc/apt/keyrings \
&& curl -fsSL https://mise.jdx.dev/gpg-key.pub | gpg --batch --yes --dearmor -o /etc/apt/keyrings/mise-archive-keyring.gpg \
&& chmod 0644 /etc/apt/keyrings/mise-archive-keyring.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/mise-archive-keyring.gpg] https://mise.jdx.dev/deb stable main" > /etc/apt/sources.list.d/mise.list \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends mise/stable \
&& rm -rf /var/lib/apt/lists/* \
&& echo 'eval "$(mise activate bash)"' >> /etc/profile \
&& mise settings set experimental true \
&& mise settings set override_tool_versions_filenames none \
&& mise settings add idiomatic_version_file_enable_tools "[]" \
&& mise settings add disable_backends asdf \
&& mise settings add disable_backends vfox
ENV PATH=$HOME/.local/share/mise/shims:$PATH
### PYTHON ###
# Trimmed to a single current Python. Add more here if a project needs them.
ARG PYTHON_VERSIONS="3.13"
# Install pyenv
ENV PYENV_ROOT=/root/.pyenv
ENV PATH=$PYENV_ROOT/bin:$PATH
RUN git -c advice.detachedHead=0 clone --depth 1 https://github.com/pyenv/pyenv.git "$PYENV_ROOT" \
&& echo 'export PYENV_ROOT="$HOME/.pyenv"' >> /etc/profile \
&& echo 'export PATH="$PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH"' >> /etc/profile \
&& echo 'eval "$(pyenv init - bash)"' >> /etc/profile \
&& cd "$PYENV_ROOT" \
&& src/configure \
&& make -C src \
&& pyenv install $PYTHON_VERSIONS \
&& rm -rf "$PYENV_ROOT/cache"
# Install pipx for common global package managers (e.g. poetry)
ENV PIPX_BIN_DIR=/root/.local/bin
ENV PATH=$PIPX_BIN_DIR:$PATH
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
--mount=type=cache,target=/root/.cache/pip \
--mount=type=cache,target=/root/.cache/pipx \
apt-get update \
&& apt-get install -y --no-install-recommends pipx=1.4.* \
&& rm -rf /var/lib/apt/lists/* \
&& pipx install --pip-args="--no-cache-dir --no-compile --root-user-action=ignore" poetry==2.1.* uv==0.7.* \
&& for pyv in "${PYENV_ROOT}/versions/"*; do \
"$pyv/bin/python" -m pip install --no-cache-dir --no-compile --root-user-action=ignore --upgrade pip && \
"$pyv/bin/pip" install --no-cache-dir --no-compile --root-user-action=ignore ruff black mypy pyright isort pytest; \
done
# Reduce the verbosity of uv - impacts performance of stdout buffering
ENV UV_NO_PROGRESS=1
### NODE ###
ARG NVM_VERSION=v0.40.2
ARG NODE_VERSION=22
ENV NVM_DIR=/root/.nvm
# Corepack tries to do too much - disable some of its features:
# https://github.com/nodejs/corepack/blob/main/README.md
ENV COREPACK_DEFAULT_TO_LATEST=0
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ENV COREPACK_ENABLE_AUTO_PIN=0
ENV COREPACK_ENABLE_STRICT=0
RUN --mount=type=cache,target=/root/.npm \
--mount=type=cache,target=/root/.cache/yarn \
--mount=type=cache,target=/root/.local/share/pnpm/store \
git -c advice.detachedHead=0 clone --branch "$NVM_VERSION" --depth 1 https://github.com/nvm-sh/nvm.git "$NVM_DIR" \
&& echo 'source $NVM_DIR/nvm.sh' >> /etc/profile \
&& echo "prettier\neslint\ntypescript" > $NVM_DIR/default-packages \
&& . $NVM_DIR/nvm.sh \
# Trimmed to a single Node version (the default). Add more `nvm install` lines here if needed.
&& nvm install "$NODE_VERSION" && nvm use "$NODE_VERSION" && npm install -g npm@11.4 pnpm@10.12 && corepack enable && corepack install -g yarn \
&& nvm alias default "$NODE_VERSION" \
&& nvm cache clear \
&& npm cache clean --force || true \
&& pnpm store prune || true \
&& yarn cache clean || true
### CLAUDE CODE ###
# Install Claude Code via the native installer (Anthropic's recommended method).
# It self-installs to /root/.local/bin/claude (already on PATH above) and does
# not depend on any particular nvm/Node version being active, so there's no
# fragile symlink to maintain. Symlink into /usr/local/bin anyway so `claude`
# resolves even in shells that haven't sourced the profile.
RUN curl -fsSL https://claude.ai/install.sh | bash \
&& ln -sf /root/.local/bin/claude /usr/local/bin/claude
### JAVA ###
ARG GRADLE_VERSION=8.14
ARG MAVEN_VERSION=3.9.10
# Trimmed to a single current LTS-era JDK to keep the image small and the build
# fast. Add older versions back here if a project needs them.
ARG AMD_JAVA_VERSIONS="25"
ARG ARM_JAVA_VERSIONS="25"
RUN --mount=type=cache,target=/root/.cache/mise \
JAVA_VERSIONS="$( [ "$TARGETARCH" = "arm64" ] && echo "$ARM_JAVA_VERSIONS" || echo "$AMD_JAVA_VERSIONS" )" \
&& for v in $JAVA_VERSIONS; do mise install "java@${v}"; done \
&& mise use --global "java@${JAVA_VERSIONS%% *}" \
&& mise use --global "gradle@${GRADLE_VERSION}" \
&& mise use --global "maven@${MAVEN_VERSION}" \
&& mise cache clear || true
### SHELL (zsh + oh-my-zsh + nano) ###
# Install zsh and nano, then oh-my-zsh unattended (RUNZSH=no keeps the build
# non-interactive, CHSH=no avoids changing root's login shell here — we set it
# explicitly afterwards so interactive `docker run` lands in zsh).
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends \
zsh=5.9-* \
nano=7.2-* \
&& rm -rf /var/lib/apt/lists/* \
&& RUNZSH=no CHSH=no KEEP_ZSHRC=yes \
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" \
# Source the existing /etc/profile (mise/nvm/pyenv/etc.) from zsh too, so all
# language toolchains are on PATH in an interactive zsh session.
&& echo 'emulate sh -c "source /etc/profile"' >> /root/.zshrc \
&& chsh -s /usr/bin/zsh root
### SETUP SCRIPTS ###
COPY setup_universal.sh /opt/codex/setup_universal.sh
RUN chmod +x /opt/codex/setup_universal.sh
### VERIFICATION SCRIPT ###
COPY verify.sh /opt/verify.sh
RUN chmod +x /opt/verify.sh \
&& PYTHON_VERSIONS="$PYTHON_VERSIONS" \
NODE_VERSIONS="$NODE_VERSION" \
JAVA_VERSIONS="$( [ "$TARGETARCH" = "arm64" ] && echo "$ARM_JAVA_VERSIONS" || echo "$AMD_JAVA_VERSIONS" )" \
"/opt/verify.sh"
### ENTRYPOINT ###
# Clear the build-only compiler parallelism caps so they don't leak into the
# running container — otherwise user-initiated compiles inside the container
# would silently inherit -j2. These only ever needed to bound peak RAM during
# the from-source builds above.
ENV MAKEFLAGS=""
ENV CARGO_BUILD_JOBS=""
COPY entrypoint.sh /opt/entrypoint.sh
RUN chmod +x /opt/entrypoint.sh
ENTRYPOINT ["/opt/entrypoint.sh"]
DOCKERFILE_EOF
cat > entrypoint.sh <<'ENTRYPOINT_EOF'
#!/bin/bash
echo "=========================================="
echo " claude-universal — Claude Code ready"
echo "=========================================="
/opt/codex/setup_universal.sh
echo ""
if command -v claude >/dev/null 2>&1; then
echo "Claude Code: $(claude --version 2>/dev/null || echo 'installed')"
else
echo "WARNING: 'claude' not found on PATH."
fi
if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
echo "Auth: ANTHROPIC_API_KEY detected (Claude Code will use it)."
else
echo "Auth: no ANTHROPIC_API_KEY set. Run 'claude' and sign in interactively,"
echo " or pass -e ANTHROPIC_API_KEY=... when starting the container."
fi
echo ""
# Auto-launch Claude Code if requested (e.g. CLAUDE_AUTOSTART=1).
if [ "${CLAUDE_AUTOSTART:-0}" = "1" ] && command -v claude >/dev/null 2>&1; then
echo "CLAUDE_AUTOSTART=1 — launching Claude Code..."
exec claude "$@"
fi
echo "Environment ready. Type 'claude' to start Claude Code. Dropping you into a shell."
# Prefer zsh (oh-my-zsh) for interactive use; fall back to bash if unavailable.
if command -v zsh >/dev/null 2>&1; then
exec zsh --login "$@"
fi
exec bash --login "$@"
ENTRYPOINT_EOF
cat > setup_universal.sh <<'SETUP_EOF'
#!/bin/bash --login
set -euo pipefail
# Accept both CLAUDE_ENV_* (preferred) and the upstream CODEX_ENV_* names for
# backward compatibility. CLAUDE_ENV_* wins if both are set.
CODEX_ENV_PYTHON_VERSION=${CLAUDE_ENV_PYTHON_VERSION:-${CODEX_ENV_PYTHON_VERSION:-}}
CODEX_ENV_NODE_VERSION=${CLAUDE_ENV_NODE_VERSION:-${CODEX_ENV_NODE_VERSION:-}}
CODEX_ENV_RUBY_VERSION=${CLAUDE_ENV_RUBY_VERSION:-${CODEX_ENV_RUBY_VERSION:-}}
CODEX_ENV_RUST_VERSION=${CLAUDE_ENV_RUST_VERSION:-${CODEX_ENV_RUST_VERSION:-}}
CODEX_ENV_GO_VERSION=${CLAUDE_ENV_GO_VERSION:-${CODEX_ENV_GO_VERSION:-}}
CODEX_ENV_SWIFT_VERSION=${CLAUDE_ENV_SWIFT_VERSION:-${CODEX_ENV_SWIFT_VERSION:-}}
CODEX_ENV_PHP_VERSION=${CLAUDE_ENV_PHP_VERSION:-${CODEX_ENV_PHP_VERSION:-}}
CODEX_ENV_JAVA_VERSION=${CLAUDE_ENV_JAVA_VERSION:-${CODEX_ENV_JAVA_VERSION:-}}
echo "Configuring language runtimes..."
# For Python and Node, always run the install commands so we can install
# global libraries for linting and formatting. This just switches the version.
# For others (e.g. rust), to save some time on bootup we only install other language toolchains
# if the versions differ.
if [ -n "${CODEX_ENV_PYTHON_VERSION}" ]; then
echo "# Python: ${CODEX_ENV_PYTHON_VERSION}"
pyenv global "${CODEX_ENV_PYTHON_VERSION}"
python3 --version
fi
if [ -n "${CODEX_ENV_NODE_VERSION}" ]; then
current=$(node -v | cut -d. -f1) # ==> v20
echo "# Node.js: v${CODEX_ENV_NODE_VERSION} (default: ${current})"
if [ "${current}" != "v${CODEX_ENV_NODE_VERSION}" ]; then
nvm alias default "${CODEX_ENV_NODE_VERSION}"
nvm use --save "${CODEX_ENV_NODE_VERSION}"
corepack enable
fi
fi
# Ruby/Rust/Go/Swift/PHP are not installed in the trimmed image. Each block is
# guarded with `command -v` so that setting one of these env vars degrades to a
# clear warning instead of aborting the whole script under `set -e` (which would
# brick container startup, since the entrypoint runs this before the shell).
if [ -n "${CODEX_ENV_RUBY_VERSION}" ]; then
if command -v ruby >/dev/null 2>&1; then
current=$(ruby -v | cut -d' ' -f2 | cut -d'p' -f1) # ==> 3.2.3
echo "# Ruby: ${CODEX_ENV_RUBY_VERSION} (default: ${current})"
if [ "${current}" != "${CODEX_ENV_RUBY_VERSION}" ]; then
mise use --global "ruby@${CODEX_ENV_RUBY_VERSION}"
ruby --version
fi
else
echo "# Ruby: requested ${CODEX_ENV_RUBY_VERSION} but ruby is not installed in this image — skipping."
fi
fi
if [ -n "${CODEX_ENV_RUST_VERSION}" ]; then
if command -v rustc >/dev/null 2>&1; then
current=$(rustc --version | awk '{print $2}') # ==> 1.86.0
echo "# Rust: ${CODEX_ENV_RUST_VERSION} (default: ${current})"
if [ "${current}" != "${CODEX_ENV_RUST_VERSION}" ]; then
rustup default "${CODEX_ENV_RUST_VERSION}"
rustc --version
fi
else
echo "# Rust: requested ${CODEX_ENV_RUST_VERSION} but rustc is not installed in this image — skipping."
fi
fi
if [ -n "${CODEX_ENV_GO_VERSION}" ]; then
if command -v go >/dev/null 2>&1; then
current=$(go version | awk '{print $3}') # ==> go1.23.8
echo "# Go: go${CODEX_ENV_GO_VERSION} (default: ${current})"
if [ "${current}" != "go${CODEX_ENV_GO_VERSION}" ]; then
mise use --global "go@${CODEX_ENV_GO_VERSION}"
go version
fi
else
echo "# Go: requested ${CODEX_ENV_GO_VERSION} but go is not installed in this image — skipping."
fi
fi
if [ -n "${CODEX_ENV_SWIFT_VERSION}" ]; then
if command -v swift >/dev/null 2>&1; then
current=$(swift --version | sed -n 's/^Swift version \([0-9]\+\.[0-9]\+\).*/\1/p') # ==> 6.2
echo "# Swift: ${CODEX_ENV_SWIFT_VERSION} (default: ${current})"
if [ "${current}" != "${CODEX_ENV_SWIFT_VERSION}" ]; then
swiftly use "${CODEX_ENV_SWIFT_VERSION}"
swift --version
fi
else
echo "# Swift: requested ${CODEX_ENV_SWIFT_VERSION} but swift is not installed in this image — skipping."
fi
fi
if [ -n "${CODEX_ENV_PHP_VERSION}" ]; then
if command -v php >/dev/null 2>&1; then
current=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
echo "# PHP: ${CODEX_ENV_PHP_VERSION} (default: ${current})"
if [ "${current}" != "${CODEX_ENV_PHP_VERSION}" ]; then
phpenv global "${CODEX_ENV_PHP_VERSION}snapshot"
php --version
fi
else
echo "# PHP: requested ${CODEX_ENV_PHP_VERSION} but php is not installed in this image — skipping."
fi
fi
if [ -n "${CODEX_ENV_JAVA_VERSION}" ]; then
current=$(java -version 2>&1 | awk -F'[ ."]+' '/version/ {print $3}')
echo "# Java: ${CODEX_ENV_JAVA_VERSION} (default: ${current})"
if [ "${current}" != "${CODEX_ENV_JAVA_VERSION}" ]; then
mise use --global "java@${CODEX_ENV_JAVA_VERSION}"
java -version
fi
fi
SETUP_EOF
cat > verify.sh <<'VERIFY_EOF'
#!/bin/bash --login
set -euo pipefail
echo "Verifying language runtimes ..."
read -ra PYTHON <<< "$PYTHON_VERSIONS"
read -ra NODE <<< "$NODE_VERSIONS"
read -ra JAVA <<< "$JAVA_VERSIONS"
max=$(printf "%s\n" \
${#PYTHON[@]} \
${#NODE[@]} \
${#JAVA[@]} \
| sort -nr | head -1)
for ((i=max-1; i>=0; i--)); do
CODEX_ENV_PYTHON_VERSION=${PYTHON[i]:-${PYTHON[0]}} \
CODEX_ENV_NODE_VERSION=${NODE[i]:-${NODE[0]}} \
CODEX_ENV_JAVA_VERSION=${JAVA[i]:-${JAVA[0]}} \
bash -lc '
printf "\n\nTesting setup_universal with versions:\n"
env | grep "^CODEX_ENV_" | sort
printf "\n"
/opt/codex/setup_universal.sh
'
done
echo "- Python:"
python3 --version
pyenv versions | sed 's/^/ /'
echo "- Node.js:"
node --version
npm --version
pnpm --version
yarn --version
npm ls -g
echo "- Java / Gradle:"
java -version
javac -version
gradle --version | head -n 3
mvn --version | head -n 1
echo "All language runtimes detected successfully."
VERIFY_EOF
chmod +x entrypoint.sh setup_universal.sh verify.sh
# Droplet-tuned compose file: detached, long-lived, auto-restart. (The repo's
# own docker-compose.yml is tuned for interactive `compose run` instead.)
cat > docker-compose.yml <<'COMPOSE_EOF'
services:
claude:
build:
context: .
args:
MAKEFLAGS: "-j2"
image: claude-universal:latest
container_name: claude
restart: unless-stopped
stdin_open: true
tty: true
init: true
# Keep the container alive in the background; you exec into it on demand.
command: ["sleep", "infinity"]
volumes:
- claude-workspace:/workspace
- claude-config:/root/.claude
working_dir: /workspace
volumes:
claude-workspace:
claude-config:
COMPOSE_EOF
# --- Build + run -------------------------------------------------------------
#log "Reclaiming any stale build cache..."
#docker builder prune -af >/dev/null 2>&1 || true
log "Building image (this takes a few minutes)..."
docker compose build --build-arg MAKEFLAGS=-j2 claude
log "Starting claude service (detached)..."
docker compose up -d claude
# --- Install the claude-shell launcher --------------------------------------
log "Installing 'claude-shell' launcher..."
cat > /usr/local/bin/claude-shell <<'LAUNCHER_EOF'
#!/usr/bin/env bash
# Open a shell (or run a command) inside the long-running claude container.
set -euo pipefail
cd /root/claude-universal
docker compose up -d claude >/dev/null 2>&1 || true
if [ "$#" -eq 0 ]; then
exec docker compose exec claude zsh --login
fi
exec docker compose exec claude "$@"
LAUNCHER_EOF
chmod +x /usr/local/bin/claude-shell
touch "$MARKER"
log "Setup complete. SSH in and run 'claude-shell', then 'claude' to sign in."
# =============================================================================
# HOW TO USE THIS GIST
# =============================================================================
# 1. Create a PUBLIC gist with this file's contents (no secrets inside — good).
# 2. Get the RAW URL pinned to a revision, e.g.
# https://gist.githubusercontent.com/<you>/<id>/raw/<REVISION>/cloud-init-gist.sh
# Pin the <REVISION> hash so a future edit can't silently change root-run code.
# 3. Create the droplet with a 2-line user-data bootstrap (this fits in 64KB):
#
# doctl compute droplet create freedcamp-employee-claude \
# --size s-4vcpu-8gb --region nyc3 --image dockeragent \
# --ssh-keys <YOUR_KEY_ID> \
# --user-data '#!/bin/bash
# curl -fsSL "https://gist.githubusercontent.com/<you>/<id>/raw/<REVISION>/cloud-init-gist.sh" -o /root/setup.sh && bash /root/setup.sh > /var/log/claude-setup.log 2>&1'
#
# 4. Watch progress (once SSH is up): ssh root@<ip> 'tail -f /var/log/claude-setup.log'
# Or: cloud-init status --wait
# 5. When done: ssh root@<ip> ; claude-shell ; claude (sign in once)
# =============================================================================
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment