Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 89 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,93 @@ RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /out/deuce .

# Stage 2: minimal runtime. distroless/static-debian12:nonroot is correct for
# CGO_ENABLED=0 static Go binaries: no glibc, no shell, no package manager.
# Runs as UID 65532.
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=backend /out/deuce /deuce
EXPOSE 8080
# Stage 2: fetch the CLI binaries the server shells out to.
#
# Deuce is not a self-contained binary at runtime: it drives `devpod` for every
# workspace operation, `docker` for the SSH proxy / SFTP / container-user lookup
# / devcontainer prebuild, and `git` for the files tab. A distroless runtime
# (the previous base) boots, migrates, and serves the SPA, then fails on the
# first session create with an exec-not-found. Both CLIs are fetched here so
# curl and the tarball never reach the runtime stage.
#
# Version pins are duplicated from .devcontainer/tool-versions.env rather than
# read from it: .dockerignore excludes .devcontainer from the build context, so
# the file is not readable here. Keep DEVPOD_VERSION in step with that file when
# bumping either one — a drift means the devcontainer and the shipped image
# drive DevPod differently.
FROM debian:bookworm-slim AS tools
ARG DEVPOD_VERSION=v0.6.15
ARG DOCKER_CLI_VERSION=29.7.0
# TARGETARCH is supplied by buildx. The release workflow builds linux/amd64
# only today; parameterizing here means adding linux/arm64 is a workflow
# change rather than a Dockerfile change.
ARG TARGETARCH
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \
case "${TARGETARCH}" in \
amd64) docker_arch="x86_64" ;; \
arm64) docker_arch="aarch64" ;; \
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
curl --fail --silent --show-error --location \
--output /tmp/docker.tgz \
"https://download.docker.com/linux/static/stable/${docker_arch}/docker-${DOCKER_CLI_VERSION}.tgz"; \
# The tarball carries the full engine; extract only the client. dockerd,
# containerd and runc have no business in this image — the daemon is the
# host's.
tar --extract --file /tmp/docker.tgz --strip-components=1 --directory /usr/local/bin docker/docker; \
curl --fail --silent --show-error --location \
--output /usr/local/bin/devpod \
"https://github.com/loft-sh/devpod/releases/download/${DEVPOD_VERSION}/devpod-linux-${TARGETARCH}"; \
chmod 0755 /usr/local/bin/docker /usr/local/bin/devpod; \
/usr/local/bin/docker --version; \
/usr/local/bin/devpod version

# Stage 3: runtime.
#
# HOME is load-bearing, not cosmetic. Both DevPod state trees resolve through
# os.UserHomeDir(): the CLI's workspace records (which supply the container
# label the reconciler matches on) and the agent's cloned workspace content
# (which the files tab reads and runs git against). The SSH host key defaults
# under it too. A deployment mounts one host directory here so all three
# survive container replacement — and, for the socket-mounted topology, so the
# path string resolves to the same directory for both Deuce and the host
# daemon. See docs/plans/2026-07-31-001-feat-vm-deploy-topology-spike-plan.md.
FROM debian:bookworm-slim
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
git \
&& rm -rf /var/lib/apt/lists/*
# UID 65532 matches the distroless `nonroot` user this image replaced, so any
# host directory already owned for the old image keeps working.
RUN groupadd --gid 65532 deuce \
&& useradd --uid 65532 --gid 65532 --home-dir /var/lib/deuce --create-home deuce
# Deuce runs git against workspace trees it does not own. DevPod clones content
# as the devcontainer's remoteUser (uid 1000 on most images), while this process
# runs as 65532, and git refuses to operate across that mismatch — it reports
# "detected dubious ownership" and exits non-zero. The files tab degrades
# quietly when that happens: the tree still lists, because walking a directory
# needs no ownership match, but every file loses its git status.
#
# Matching the uids is not available as a fix. remoteUser varies per
# devcontainer image, one deployment serves many repos at once, and the value
# is not known until after the workspace is built. Declaring the trees safe is
# the accurate description of the situation: this container's entire job is
# reading repositories owned by other uids, which is the case git's ownership
# check was never meant to cover.
RUN git config --system --add safe.directory '*'
COPY --from=tools /usr/local/bin/docker /usr/local/bin/docker
COPY --from=tools /usr/local/bin/devpod /usr/local/bin/devpod
COPY --from=backend /out/deuce /usr/local/bin/deuce
ENV HOME=/var/lib/deuce
WORKDIR /var/lib/deuce
# 8080 HTTP (API + WS + embedded SPA), 2222 embedded SSH proxy for
# "Open in VS Code" (DEUCE_SSH_LISTEN_ADDR default).
EXPOSE 8080 2222
USER 65532:65532
ENTRYPOINT ["/deuce"]
ENTRYPOINT ["/usr/local/bin/deuce"]
90 changes: 90 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Deuce deployment configuration — SPIKE CANDIDATE.
#
# Copy to .env in this directory: cp .env.example .env
# This is the deployment env file. It is NOT the same as .env.example at the
# repo root, which configures a developer laptop.
#
# ── A GOTCHA THAT WILL COST YOU AN HOUR ────────────────────────────────────
# Leaving a variable empty here does NOT mean "unset" or "off". The config
# loader falls back to the built-in default when a variable is set-but-empty,
# exactly as if you had never written the line. Verified against a running
# container, not inferred.
#
# So `DEUCE_WS_ALLOWED_ORIGINS=` does not mean "no origins allowed" — it means
# "localhost:4000,localhost:8080", which is a dev default you do not want on a
# server. To change a setting, give it a real value. To get default behavior,
# delete the line. There is no way to express "empty" through this file.
# ───────────────────────────────────────────────────────────────────────────

# ── Required. The compose file refuses to start without these. ─────────────

# Released image tag to run. Never a floating tag — an upgrade should be a
# deliberate edit here and a rollback the reverse edit. Note the tag has no
# leading "v": the git tag v0.2.0 publishes the image tag 0.2.0.
DEUCE_IMAGE_TAG=

# Absolute host path holding everything that must survive a container
# replacement: DevPod's workspace records and cloned workspace content, and the
# SSH host key. It is mounted at this same absolute path INSIDE the container,
# and HOME is set to it. Both sides must match — see the compose file header
# for what breaks when they don't, and how quietly it breaks.
#
# Create it owned by the container's UID before first start:
# sudo mkdir -p /var/lib/deuce && sudo chown 65532:65532 /var/lib/deuce
DEUCE_STATE_DIR=/var/lib/deuce

# Numeric group id of the host's docker group, so the container can reach the
# mounted socket. Host-specific:
# getent group docker | cut -d: -f3
DOCKER_GID=

# Database password. Generate one: openssl rand -hex 32
POSTGRES_PASSWORD=

# ── Exposure ───────────────────────────────────────────────────────────────
# Defaults bind loopback only. Read the auth warning below before widening.

DEUCE_HTTP_BIND=127.0.0.1
DEUCE_HTTP_PORT=8080

# The SSH proxy backing "Open in VS Code". Reaching it from a VS Code client
# means widening this bind and letting the tailnet be the boundary.
DEUCE_SSH_BIND=127.0.0.1
DEUCE_SSH_PORT=2222

# ── Auth ───────────────────────────────────────────────────────────────────
#
# TEMPORARY SPIKE POSTURE. `dev` mode admits ANY request as a single fixed
# user — anyone who can reach the port is fully authenticated as that user.
# It is set here only because the spike is testing deployment topology, not
# exposure, on a private VM. It must not survive into a real deployment.
#
# The intended default is proxy mode behind Tailscale Serve, which is planned
# work and not yet wired up here. Until it is, keep the binds on loopback.
DEUCE_AUTH_MODE=dev
DEUCE_USER_ID=10000000-0000-0000-0000-000000000001

# Hostname the deployment is reached at. Used for WebSocket origin checks and
# baked into the vscode:// URIs the UI hands out, so a wrong value produces
# "Open in VS Code" links that point somewhere unreachable.
DEUCE_WS_ALLOWED_ORIGINS=localhost:8080
DEUCE_PUBLIC_HOSTNAME=localhost

# ── Workspaces ─────────────────────────────────────────────────────────────

DEVPOD_PROVIDER=docker

# GitHub PAT for repo discovery in the session-create flow. Sessions clone
# their repository inside the container, so this also has to carry enough
# access for whatever repos you point the deployment at.
GITHUB_TOKEN=

# Required for the agent to actually run inside a workspace.
ANTHROPIC_API_KEY=

# Devcontainer prebuild cache. Deliberately left off for the first spike pass:
# the cache key is derived only from the devcontainer definition hash, so a
# Deuce upgrade that bakes newer agent tooling reuses the stale image and the
# upgrade check would report a caching result as if it were a topology result.
# Enable it only for the second pass, with that behavior in mind.
# DEUCE_PREBUILD_REPOSITORY=deuce-prebuild
82 changes: 82 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Deuce single-VM deployment — SPIKE CANDIDATE, not yet validated.
#
# This is the "socket mount with path parity" topology under test in
# docs/plans/2026-07-31-001-feat-vm-deploy-topology-spike-plan.md. Do not treat
# it as a supported deployment until that spike records a decision.
#
# THE ONE THING THAT MATTERS HERE
#
# Deuce does not read workspace files through DevPod. It reads them off its own
# filesystem and runs git against them, and it locates workspace containers by a
# label it reads from DevPod's own on-disk records. Both of those trees resolve
# through $HOME. So Deuce and the host Docker daemon must agree on what a path
# string means: DEUCE_STATE_DIR is mounted at the SAME ABSOLUTE PATH on both
# sides, and HOME points at it.
#
# Break that parity and nothing errors. DevPod succeeds, the container starts,
# and the bind mount silently resolves to an empty host directory — the files
# tab shows nothing while the agent works normally somewhere nobody is looking.
# If you change the mount, change both sides together.
#
# Copy .env.example to .env in this directory before bringing the stack up.
# The required-variable checks below fail loudly rather than defaulting, because
# every one of them is something that silently misbehaves when guessed.

services:
deuce:
# Pinned tag, never a floating one: an upgrade should be a deliberate edit
# and a rollback the reverse edit.
image: ghcr.io/forgeutah/deuce:${DEUCE_IMAGE_TAG:?set DEUCE_IMAGE_TAG to a released tag, e.g. 0.2.0 — floating tags make rollback ambiguous}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
env_file:
- .env
environment:
# Overrides anything in .env. HOME is what makes the mount below
# meaningful — see the header.
HOME: ${DEUCE_STATE_DIR:?set DEUCE_STATE_DIR to an absolute host path, e.g. /var/lib/deuce}
DATABASE_URL: postgres://deuce:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/deuce?sslmode=disable
volumes:
# The host daemon Deuce drives. Workspace containers are created as
# siblings of this container, not as children.
- /var/run/docker.sock:/var/run/docker.sock
# Path parity. Both sides are the same string on purpose.
- ${DEUCE_STATE_DIR:?set DEUCE_STATE_DIR}:${DEUCE_STATE_DIR:?set DEUCE_STATE_DIR}
# The container runs as UID 65532 and needs the host's docker group to reach
# the socket. This gid is host-specific — find it with:
# getent group docker | cut -d: -f3
group_add:
- "${DOCKER_GID:?set DOCKER_GID to the host docker group id — see the comment above}"
ports:
# Loopback by default. In dev auth mode ANY reachable client is admitted
# as any user, so this must not be published broadly until proxy auth is
# configured. Front it with `tailscale serve`, which proxies from
# localhost, or tunnel to it.
- "${DEUCE_HTTP_BIND:-127.0.0.1}:${DEUCE_HTTP_PORT:-8080}:8080"
# The SSH proxy for "Open in VS Code". Must be reachable by the VS Code
# client to exercise that path, which means widening this bind and
# relying on the tailnet as the boundary.
- "${DEUCE_SSH_BIND:-127.0.0.1}:${DEUCE_SSH_PORT:-2222}:2222"

postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: deuce
POSTGRES_USER: deuce
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
# Gates deuce's start. Migrations run in-process before the HTTP listener
# binds and exit non-zero on failure, so a half-migrated schema never
# serves — but the app still has to find a live database first.
test: ["CMD-SHELL", "pg_isready -U deuce -d deuce"]
interval: 5s
timeout: 5s
retries: 10

volumes:
pgdata:
Loading
Loading