taproot
mirrorThe dotfiles and containers I use to set up a machine for development, one container to write code in and another that runs a local coding model on the desktop's GPU.
alpine-linuxcaddydevelopment-environmentdockerdotfileshomelabinfrastructureneovimserver-configurationtmux
1# webdev
2#
3# Debian 13. Go, Python (uv), Bun, Claude Code, Docker CLI, gh, typst, Playwright.
4# No nodejs, no npm, no Rust. Work lives in the four bythewood-* volumes, so the
5# container itself is disposable.
6#
7# Driven by the Makefile at the root of this repo, which is also the build
8# context, and where every command lives. From a clone:
9#
10# make up create whatever is missing and start it, safe to re-run
11# make update rebuild this image and replace the running container
12# make doctor what exists, what is running, and what to type next
13# make shell get in, via tmux
14#
15# A machine that has never run this needs Docker and a git key at
16# ~/.ssh/home_key that is on GitHub, then one command:
17#
18# make install the above, plus the key, the B2 credentials and a restore
19#
20# `make help` lists the rest and `make doctor` reports on all of it.
21#
22# Gotchas: --init or tmux leaves zombies. docker.sock is root:root 660, so
23# docker needs sudo in here. --publish is what makes port 8000 reachable from
24# the host browser. Bind mounts under /home/dev silently mount empty, because
25# the daemon is Docker Desktop and resolves paths on the Windows side, so use
26# named volumes.
27
28FROM debian:trixie-slim
29
30# BUN_INSTALL is persistent: it also decides where `bun install -g` puts globals.
31ENV DEBIAN_FRONTEND=noninteractive \
32 TZ=UTC \
33 BUN_INSTALL=/usr/local \
34 LANG=C.UTF-8 \
35 LC_ALL=C.UTF-8 \
36 TERM=xterm-256color \
37 PATH="/home/dev/.local/bin:/home/dev/scripts:/usr/local/go/bin:/home/dev/go/bin:$PATH"
38
39# Docker's repo, not Debian's, since trixie has no docker-compose-v2 package.
40# Client only, never the daemon. jq is required by code-sync, and there is no
41# pip or python3-venv because uv covers both.
42RUN apt-get update && \
43 apt-get install -y --no-install-recommends ca-certificates curl && \
44 install -m 0755 -d /etc/apt/keyrings && \
45 curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \
46 chmod a+r /etc/apt/keyrings/docker.asc && \
47 echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian trixie stable" \
48 > /etc/apt/sources.list.d/docker.list && \
49 apt-get update && \
50 apt-get install -y --no-install-recommends \
51 git rsync neovim openssh-client tmux \
52 unzip xz-utils htop tree sudo jq restic sqlite3 poppler-utils \
53 build-essential tzdata python3 \
54 docker-ce-cli docker-compose-plugin docker-buildx-plugin
55
56RUN curl -fsSL https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh && \
57 curl -fsSL https://bun.sh/install | bash
58
59# npm-published CLIs have a `#!/usr/bin/env node` shebang and die without this
60# shim. `node --version` does not work under it, hence the real script.
61RUN ln -sf /usr/local/bin/bun /usr/local/bin/node && \
62 echo 'console.log("node shim ok")' > /tmp/shim.js && \
63 node /tmp/shim.js && rm /tmp/shim.js
64
65# Upstream tarball, not apt, which lags releases.
66ARG GO_VERSION=1.27.0
67ARG GO_SHA256=675c26c449cbb18fc24b74650de1eabbae6e16f64326fd85a283fb3b58280685
68RUN curl -fsSLO "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" && \
69 echo "${GO_SHA256} go${GO_VERSION}.linux-amd64.tar.gz" | sha256sum -c - && \
70 tar -C /usr/local -xzf "go${GO_VERSION}.linux-amd64.tar.gz" && \
71 rm "go${GO_VERSION}.linux-amd64.tar.gz" && \
72 go version
73
74# Pinned to the version orchard's sites use. musl is the only linux x86_64 build
75# typst ships, and it is static so it runs fine on glibc.
76ARG TYPST_VERSION=0.14.0
77ARG TYPST_SHA256=99816d2982de08d2b091bac56b59b2faa523a10e1378ad3cdd68e35b8eb74b3d
78RUN curl -fsSL -o /tmp/typst.tar.xz \
79 "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-x86_64-unknown-linux-musl.tar.xz" && \
80 echo "${TYPST_SHA256} /tmp/typst.tar.xz" | sha256sum -c - && \
81 tar -xJf /tmp/typst.tar.xz -C /tmp && \
82 mv /tmp/typst-x86_64-unknown-linux-musl/typst /usr/local/bin/typst && \
83 rm -rf /tmp/typst.tar.xz /tmp/typst-x86_64-unknown-linux-musl && \
84 typst --version
85
86# Debian does not package gh, and GitHub's apt repo would be a second keyring
87# for one binary.
88ARG GH_VERSION=2.98.0
89ARG GH_SHA256=3b8ac6b30336802fc1a858d7c084e11cdf24ac1a761ca90b68022d7d729208de
90RUN curl -fsSL -o /tmp/gh.tar.gz \
91 "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" && \
92 echo "${GH_SHA256} /tmp/gh.tar.gz" | sha256sum -c - && \
93 tar -xzf /tmp/gh.tar.gz -C /tmp && \
94 mv "/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh && \
95 rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_amd64" && \
96 gh --version
97
98# The pin matters. 0.1.14 drives a page, while 0.1.18 and unpinned both die with
99# "No usable sandbox" under Docker Desktop on WSL2, so drive a real page after
100# any bump. Some tools want Chrome at the /opt path, hence the symlink.
101ARG PLAYWRIGHT_CLI_VERSION=0.1.14
102ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
103RUN bun install -g @playwright/cli@${PLAYWRIGHT_CLI_VERSION} && \
104 playwright-cli --help >/dev/null && \
105 CORE="$(find /usr/local/install/global -path '*/playwright-core/cli.js' | head -1)" && \
106 test -n "$CORE" && \
107 node "$CORE" install --with-deps chromium && \
108 chmod -R a+rx /opt/playwright-browsers && \
109 mkdir -p /opt/google/chrome && \
110 ln -snf /opt/playwright-browsers/chromium-*/chrome-linux64/chrome /opt/google/chrome/chrome && \
111 test -x /opt/google/chrome/chrome && \
112 rm -rf /var/lib/apt/lists/*
113
114RUN ln -snf /usr/share/zoneinfo/UTC /etc/localtime && echo "UTC" > /etc/timezone
115
116# Debian's /etc/profile assigns PATH outright, so a login shell (what tmux panes
117# get) drops everything the ENV added. The ENV still covers plain `docker exec`.
118RUN printf '%s\n' \
119 'export PATH="/home/dev/.local/bin:/home/dev/scripts:/usr/local/go/bin:/home/dev/go/bin:$PATH"' \
120 > /etc/profile.d/10-dev-path.sh && chmod 644 /etc/profile.d/10-dev-path.sh
121
122# Everything in the bythewood-* volumes is owned by 1001 and Debian has no
123# default 1000 user, so an unpinned useradd takes 1000 and every volume mounts
124# owned by a stranger. Do not remove the -u.
125RUN useradd -m -u 1001 -G sudo -s /bin/bash dev && \
126 echo "dev ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/dev
127
128COPY dotfiles/bash_aliases /home/dev/.bash_aliases
129COPY dotfiles/gitconfig /home/dev/.gitconfig
130COPY dotfiles/neovim/init.lua /home/dev/.config/nvim/init.lua
131COPY dotfiles/tmux.conf /home/dev/.tmux.conf
132COPY containers/webdev/scripts/ /home/dev/scripts/
133
134# The file tree sidebar, as a native package so there is no plugin manager and
135# nothing to fetch on first run. Pinned because nvim here is 0.10, which is the
136# oldest release nvim-tree still supports.
137RUN git clone --quiet --depth 1 --branch v1.18.0 \
138 https://github.com/nvim-tree/nvim-tree.lua.git \
139 /home/dev/.config/nvim/pack/dev/start/nvim-tree.lua && \
140 rm -rf /home/dev/.config/nvim/pack/dev/start/nvim-tree.lua/.git
141
142# .claude.json lives in the volume so auth survives a rebuild.
143RUN mkdir -p /home/dev/code /home/dev/.claude /home/dev/.ssh /home/dev/.restic && \
144 for f in /home/dev/scripts/*.sh; do mv "$f" "${f%.sh}"; done && \
145 chmod +x /home/dev/scripts/* && \
146 echo "source ~/.bash_aliases" >> /home/dev/.bashrc && \
147 ln -s /home/dev/.claude/.claude.json /home/dev/.claude.json && \
148 ln -s code/CLAUDE.md /home/dev/CLAUDE.md && \
149 printf '%s\n' \
150 'Host *' \
151 ' IdentityFile ~/.ssh/home_key' \
152 ' IdentitiesOnly yes' \
153 ' StrictHostKeyChecking accept-new' \
154 ' UpdateHostKeys yes' \
155 ' HashKnownHosts yes' \
156 ' PasswordAuthentication no' \
157 ' ServerAliveInterval 60' \
158 ' ServerAliveCountMax 3' \
159 ' VisualHostKey yes' \
160 > /home/dev/.ssh/config && \
161 chmod 700 /home/dev/.ssh /home/dev/.restic && \
162 chmod 600 /home/dev/.ssh/config && \
163 chown -R dev:dev /home/dev
164
165WORKDIR /home/dev
166USER dev
167
168RUN curl -fsSL https://claude.ai/install.sh | bash
169
170CMD ["sleep", "infinity"]