Skip to content
Open
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
45 changes: 45 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Build artifacts — rebuilt fresh inside the image.
_build/
deps/
.elixir_ls/
.serena/

# Dev/test databases — must never leak into the runtime image.
*.db
*.db-*

# Local Claude worktrees and credentials — stay on the host.
.claude/
.env*
mise.local.toml

# Asset build outputs — regenerated by `mix assets.deploy`.
priv/static/assets/
priv/static/cache_manifest.json
assets/node_modules/

# Unrelated trees.
landing/
docs/
tmp/
test/
cover/
doc/

# VCS / OS / dumps.
.git/
.github/
.githooks/
.gitignore
.DS_Store
erl_crash.dump
*.ez

# Documentation / meta (not needed at runtime).
AGENTS.md
CLAUDE.md
README.md

# The Dockerfile itself (and this file) don't need to be in the context
# that Docker sends, but we keep them for convenience — exclude nothing
# else by default.
56 changes: 56 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Publish Docker image

on:
push:
branches: [main]
tags: ["v*.*.*"]
workflow_dispatch:

jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

concurrency:
group: ghcr-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}

steps:
- uses: actions/checkout@v4

- uses: docker/setup-qemu-action@v3
with:
platforms: arm64

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/esnunes/destila
tags: |
type=ref,event=branch
type=sha,format=short
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}

- uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
167 changes: 167 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# syntax=docker/dockerfile:1.7
#
# Multi-stage Dockerfile for Destila.
#
# Stage 1 ("build") fetches and compiles deps + the application for
# `MIX_ENV=prod` and deploys assets. Erlang, Elixir, and Node come from
# `mise.toml` via mise — the same source of truth used in dev — so OTP
# versions cannot drift between local and CI.
#
# Stage 2 ("runtime") ships the compiled source tree alongside every CLI
# Destila expects on `PATH` (`claude`, `tmux`, `ffmpeg`, `agent-browser`,
# `git`) plus Chromium for `agent-browser`. The container runs the server
# with `elixir --sname destila -S mix phx.server`, redirected to
# /root/.cache/destila/services/project-destila-main.log, so the start
# command matches how Destila launches its own managed services.
#
# Tool sourcing strategy:
# - mise: erlang, elixir, node (from `mise.toml`); ffmpeg, tmux at runtime
# - apt: system libraries the BEAM/chromium link against, tini for PID 1,
# build deps required to compile OTP from source, plus git/curl
# - npm (via mise's node): @every/agent-browser
# - Anthropic install script: claude CLI
#
# Published image: ghcr.io/esnunes/destila
#
# See `.github/workflows/docker-publish.yml` for the CI build.

ARG DEBIAN_IMAGE=debian:bookworm-slim

# -----------------------------------------------------------------------------
# Build stage
# -----------------------------------------------------------------------------
FROM ${DEBIAN_IMAGE} AS build

ENV LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
MIX_ENV=prod \
MISE_DATA_DIR=/mise \
MISE_CONFIG_DIR=/mise \
MISE_CACHE_DIR=/mise/cache \
MISE_INSTALL_PATH=/usr/local/bin/mise \
PATH=/mise/shims:/usr/local/bin:/usr/bin:/bin

# System deps mise cannot provide: compilers, headers, and helpers required to
# build OTP from source (kerl) and compile native Elixir deps.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
autoconf \
build-essential \
ca-certificates \
cmake \
curl \
git \
libncurses-dev \
libssl-dev \
m4 \
pkg-config \
unzip \
&& rm -rf /var/lib/apt/lists/*

RUN curl https://mise.run | sh

WORKDIR /app

# `mise install` reads mise.toml — so erlang, elixir, and node all come from
# the same pinned versions used in development.
COPY mise.toml ./
RUN mise trust mise.toml \
&& mise install

RUN mix local.hex --force \
&& mix local.rebar --force

# Dependency compilation layer — cached until mix.exs / mix.lock change.
COPY mix.exs mix.lock ./
RUN mix deps.get --only prod

COPY config/config.exs config/prod.exs config/
RUN mix deps.compile

# JS deps for the asset pipeline.
COPY assets/package.json assets/package-lock.json assets/
RUN cd assets && npm ci --no-audit --no-fund

COPY priv priv
COPY lib lib
COPY assets assets

# Runtime config is evaluated at boot, not build — copy last so it does
# not bust the compile cache.
COPY config/runtime.exs config/

RUN mix compile
RUN mix assets.deploy

# -----------------------------------------------------------------------------
# Runtime stage
# -----------------------------------------------------------------------------
FROM ${DEBIAN_IMAGE} AS runtime

ENV LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
HOME=/root \
MIX_ENV=prod \
MISE_DATA_DIR=/mise \
MISE_CONFIG_DIR=/mise \
MISE_CACHE_DIR=/mise/cache \
MISE_INSTALL_PATH=/usr/local/bin/mise \
PATH=/root/.local/bin:/mise/shims:/usr/local/bin:/usr/bin:/bin \
DATABASE_PATH=/data/destila.db \
PHX_SERVER=true \
PHX_HOST=localhost \
PORT=4000 \
PUPPETEER_SKIP_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \
CHROME_PATH=/usr/bin/chromium

# System deps mise cannot replace: chromium (apt-only), the runtime libs the
# BEAM links against (ncurses/libstdc++/openssl), locales, tini for PID 1,
# git for `Destila.Git`, plus ca-certificates / curl for the claude installer.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
chromium \
curl \
git \
libncurses6 \
libstdc++6 \
locales \
openssl \
tini \
&& rm -rf /var/lib/apt/lists/*

# Reuse erlang/elixir/node from the build stage to avoid recompiling OTP.
COPY --from=build /usr/local/bin/mise /usr/local/bin/mise
COPY --from=build /mise /mise

# Add ffmpeg and tmux from mise — quick aqua downloads, no compilation.
RUN mise use --global ffmpeg tmux \
&& mise install

# agent-browser — installed via npm using mise's node. `mise reshim` ensures
# the new bin is exposed via /mise/shims.
RUN npm install -g --omit=dev @every/agent-browser \
&& npm cache clean --force \
&& mise reshim

# Claude Code CLI — official installer; no mise plugin available.
RUN curl -fsSL https://claude.ai/install.sh | bash \
&& test -x /root/.local/bin/claude

WORKDIR /app

# Ship the compiled source tree from build (lib, _build/prod, deps, priv,
# config, mix.exs/mix.lock) so `mix phx.server` can boot directly. mtimes are
# preserved by the stage-to-stage COPY, so mix's incremental compiler does
# not rebuild on first start.
COPY --from=build /app /app

COPY docker/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh

VOLUME ["/root/.claude", "/root/.cache/destila", "/data"]

EXPOSE 4000

ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"]
119 changes: 119 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,125 @@

Destila is an AI-powered workflow orchestration tool for software development. It manages multi-phase, AI-assisted workflows that take developers from rough ideas to implemented code.

## Run with Docker

An official container image is published to the GitHub Container Registry at
[`ghcr.io/esnunes/destila`](https://github.com/esnunes/destila/pkgs/container/destila).
It ships with Destila plus every required CLI pre-installed (`claude`, `tmux`,
`ffmpeg`, `agent-browser`, `git`) so you can run Destila without installing
Elixir, Erlang, Node, or the Claude Code CLI on your host.

### 1. Pull the image

```sh
docker pull ghcr.io/esnunes/destila:latest
```

Pin to a specific version (recommended for anything beyond a quick try) by
using a semver tag such as `ghcr.io/esnunes/destila:0.1.0`.

### 2. Generate a `SECRET_KEY_BASE`

Phoenix refuses to boot without a signing secret. Generate one once and reuse it:

```sh
export SECRET_KEY_BASE=$(openssl rand -hex 64)
```

### 3. Choose an authentication method

The three options below are mutually exclusive and resolved in the same
priority order as the [Authentication](#authentication) section. Pick one:

- **OAuth token (Claude subscription)** — pass via env var:

```sh
-e CLAUDE_AGENT_OAUTH_TOKEN="sk-ant-oat01-..."
```

- **Anthropic API key** — pass via env var:

```sh
-e ANTHROPIC_API_KEY="sk-ant-api03-..."
```

- **Pre-logged-in host** — mount your host `~/.claude` directory so the
container reuses an existing `claude login` session:

```sh
-v ~/.claude:/root/.claude
```

### 4. Run the container

```sh
mkdir -p ~/destila-data ~/.cache/destila

docker run -d \
--name destila \
-p 4000:4000 \
-v ~/.claude:/root/.claude \
-v ~/.cache/destila:/root/.cache/destila \
-v ~/destila-data:/data \
-e SECRET_KEY_BASE="$SECRET_KEY_BASE" \
-e PHX_HOST=localhost \
ghcr.io/esnunes/destila:latest
```

Open http://localhost:4000. Migrations run automatically on first boot; the
SQLite database lives at `/data/destila.db` (i.e. on the `~/destila-data`
mount).

### 5. Upgrade

```sh
docker pull ghcr.io/esnunes/destila:latest
docker rm -f destila
docker run -d --name destila ... # same flags as step 4
```

Your projects, sessions, Claude login, and SQLite database all live on the
three mounted volumes and survive container recreation.

### Volumes

| Host path | Container path | Purpose |
| ------------------ | ----------------------- | -------------------------------------------------------------- |
| `~/.claude` | `/root/.claude` | Claude Code CLI credentials and settings. |
| `~/.cache/destila` | `/root/.cache/destila` | Per-project git clones and workflow-session worktrees. |
| `~/destila-data` | `/data` | SQLite database (`destila.db` plus its WAL/SHM sidecar files). |

> The cache mount maps the host's `~/.cache/destila` — **not** `~/.cache`.
> Destila adds the `destila/` segment itself at `lib/destila/git.ex`; if you
> mount `~/.cache` you'll end up with clones at `~/.cache/destila/destila/...`.

### Limitations

- Services launched by Destila workflows bind to dynamic host ports inside
the container and are therefore only reachable from the host if you run
with `--network host` (Linux only) or forward the needed ports with
additional `-p` flags at `docker run` time.
- The image is published as a multi-arch manifest covering `linux/amd64`
and `linux/arm64`, so Apple Silicon, Raspberry Pi, and arm64 servers
pull a native image without emulation.
- The container runs as `root` by default. Running with
`--user $(id -u):$(id -g)` is possible but requires pre-creating the
bind-mount directories (`~/.claude`, `~/.cache/destila`, `~/destila-data`)
with matching ownership on the host — otherwise SQLite and the Claude CLI
will hit `EACCES` on first write, since the image's `/data`, `/root/.claude`,
and `/root/.cache/destila` paths are owned by `root` inside the image.

### Troubleshooting

- **`environment variable SECRET_KEY_BASE is missing`** — set
`SECRET_KEY_BASE` on the `docker run` command line (see step 2).
- **Permission denied on volume mounts under SELinux / Podman** — append
`:Z` to each bind mount, e.g. `-v ~/.claude:/root/.claude:Z`.
- **`Destila.Deps.check/0` reports `available?: false`** — rebuild the image
from a clean checkout (`docker pull` for the official tag, or
`docker build --no-cache` for a local build). The required CLIs are baked
into the image and should never be missing at runtime.

## Getting started

To start your Phoenix server:
Expand Down
Loading