A web-based Docker Compose stack manager with Git integration, backups, and a built-in terminal. Capstan ships as a single multi-arch container that serves both the API and the web UI on one port.
Pre-built multi-arch images (linux/amd64, linux/arm64) are published to the
GitHub Container Registry on each release:
docker pull ghcr.io/thinkbig1979/capstan:latestThe fastest way to run it is with docker-compose.prod.yaml, which already
points at the published image. Create a .env first (see
Production Deployment), then:
docker compose -f docker-compose.prod.yaml up -dPin a version tag (e.g. ghcr.io/thinkbig1979/capstan:0.11.0) for reproducible
deployments; :latest tracks the most recent release.
start-local.sh builds the same all-in-one image and runs it via
docker-compose.yaml, serving the whole app on port 5001:
./start-local.shThen open http://localhost:5001. Authentication is disabled in this local mode
(backend/.env is created from backend/.env.example). Set STACKS_DIR in
docker-compose.yaml to the directory that holds your compose projects.
For frontend hot-reload, run the backend and the Vite dev server separately:
cd backend && ./run-local.sh # API on :5001
cd frontend && ./run-dev.sh # Vite dev server on :5173 (proxies /api to :5001)- Docker Compose Management: create, start, stop, restart, and delete stacks
- Bulk Actions: select multiple stacks and start, stop, restart, or pull them together
- Pinned Stacks: keep frequently-used stacks at the top of the sidebar
- Command Palette: press
⌘K/Ctrl-Kto jump to any stack, action, or settings page - Log Viewer: ANSI-colored output, per-container colors, container and level filters, search, and preferences that persist across sessions
- Compose Editor: edit
docker-compose.yamlfiles with live linting - Environment Files: manage
.envfiles with comment preservation - Git Integration: status, pull, log, and diff for git-managed stacks
- Image Updates: detect and apply image updates per service, with an at-a-glance count in the sidebar
- Dashboard Metrics: sortable CPU and memory view with per-container sparklines
- Backups: built-in restic snapshots with optional rclone cloud sync, with last-run and next-run shown in the sidebar
- Web Terminal: an in-browser shell into running containers
- Real-time Updates: file watching for automatic stack detection
- Action Logging: an audit trail of all operations
Capstan includes a built-in backup engine powered by restic and rclone, both shipped inside the container image at pinned versions:
| Tool | Version | Purpose |
|---|---|---|
| restic | 0.18.0 | Local encrypted snapshot backups |
| rclone | 1.68.2 | Cloud sync / offsite DR copies |
Each stack's compose directory is backed up as a restic snapshot tagged with the stack ID. Backups can be triggered manually (Settings, Backup tab) or run on a schedule.
The quickest path is the Settings UI (Settings, Backup). All fields save to the encrypted database and take effect without a restart.
Alternatively, set env vars in your .env file (see .env.example for the full list).
The UI always wins over env vars.
Key variables:
| Variable | Default | Notes |
|---|---|---|
RESTIC_REPOSITORY |
/app/data/restic-repo |
Path inside the container |
RESTIC_PASSWORD |
(none) | Required; stored encrypted when set in UI |
RCLONE_REMOTE |
(none) | rclone remote name (optional) |
RCLONE_PATH |
capstan-backups |
Destination path on the remote |
The restic repository lives inside /app/data. Your compose file MUST mount this
as a host bind mount so that snapshots survive container recreation:
volumes:
- ./data:/app/data # host bind mount — required for backup persistenceNever replace this with a Docker named volume. The docker-compose.prod.yaml and
docker/compose.yaml both use a bind mount by default.
# Via the UI: Settings → Backup → Run Backup Now
# Via the API:
curl -X POST http://localhost:5001/api/v1/backups/runConfigure an rclone remote and set RCLONE_REMOTE (or use the UI). Enable
"Sync after backup" to push every snapshot to the remote automatically.
Rclone config file: mount it read-only into the container if you manage it externally:
volumes:
- ~/.config/rclone/rclone.conf:/home/appuser/.config/rclone/rclone.conf:ro# List snapshots for a stack:
curl http://localhost:5001/api/v1/backups/snapshots?stackId=<id>
# Restore a snapshot via UI: Settings → Backup → Snapshots → Restore
# Or via API:
curl -X POST http://localhost:5001/api/v1/backups/restore \
-H 'Content-Type: application/json' \
-d '{"stackId":"<id>","snapshotId":"<short-id>"}'If the host is lost, recover from an rclone remote:
- Deploy a fresh Capstan instance with the same
./databind mount path. - Trigger a DR restore (Settings, Backup, DR Restore). This syncs the full
restic repository from the configured remote back to
/app/data/restic-repo. - Restore individual stacks via the Snapshots panel.
The restic repository password is required for DR recovery. Store it separately from the server (e.g. a password manager).
Important: the STACKS_DIR path inside the container must match the path on
the host for Docker Compose operations to work correctly. Compose records the
project's directory, and the host's Docker daemon must be able to find that same
path when Capstan runs commands against it.
Set both variables to the same value:
environment:
- STACKS_DIR=/opt/stacks
- HOST_STACKS_DIR=/opt/stacksOn startup, Capstan validates path identity and logs a warning if the paths don't match:
docker compose logs | grep "Volume path identity"Capstan manages your stacks by talking to the host's Docker daemon through the
mounted socket (/var/run/docker.sock). A few things worth understanding:
You do not need to create users, edit groups, or change any permissions on your host. Just mount the socket and a data directory:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/app/data
- /opt/stacks:/opt/stacks # your compose projectsOn startup the container briefly runs as root only to:
- discover the actual group GID of the mounted socket (it differs per
host — Debian/Ubuntu often use
999, others998/130/…) and join it, and - align its runtime user to your file owner,
then it drops privileges and runs the app as the non-root appuser. Because the
socket's group is detected at runtime, the same image works on any host without
rebuilding.
appuser defaults to UID/GID 1000 — the typical first Linux user, so on most
single-user hosts it "just works" and your stack files stay editable from the
host. If the user that owns your stacks/data is a different ID, set:
environment:
- PUID=1001
- PGID=1001Capstan owns and chowns its own ./data dir; it does not rewrite ownership
of your existing compose projects — it matches their owner via PUID/PGID instead.
Anyone who can reach /var/run/docker.sock has root-equivalent control of the
host (the Docker API can start a privileged container that mounts /). This is
true regardless of the in-container user, so the non-root appuser is
defense-in-depth for Capstan's own files — not a containment boundary for Docker
itself. Two consequences:
-
:roon the socket mount is cosmetic. It makes the socket file read-only but the Docker API still accepts write commands (create/start/delete) through it, so it is not a safeguard. Capstan does not use it. -
For real least-privilege, put a socket proxy in front of Capstan and expose only the API endpoints it needs (containers, exec, and the system/version endpoints), denying the rest:
services: docker-proxy: image: tecnativa/docker-socket-proxy environment: CONTAINERS: 1 SERVICES: 1 TASKS: 1 POST: 1 # required for start/stop/create EXEC: 1 # required for the in-app terminal volumes: - /var/run/docker.sock:/var/run/docker.sock:ro networks: [capstan-network] capstan: image: ghcr.io/thinkbig1979/capstan:latest environment: - DOCKER_HOST=tcp://docker-proxy:2375 # no socket mount on Capstan itself networks: [capstan-network]
Capstan holds credentials, runs commands against your host's Docker daemon, and serves an in-browser terminal, so the application is hardened by default rather than left to the operator to secure. The codebase has been through a security audit; the measures below are in place and covered by tests.
Authentication and sessions
- Authentication is on by default. Passwords are hashed with bcrypt and must meet a strength policy (length, character classes, common-password blocklist).
- Login is rate-limited and runs a constant-time comparison whether or not the username exists, so it does not leak which accounts are valid.
- Sessions are tracked server-side and revoked on logout and on password change.
The session cookie is
HttpOnly,SameSite=Lax, and markedSecurewhen the request arrives over HTTPS. JWTs are bound to an issuer claim.
Secrets at rest
- Stored secrets (git HTTPS tokens, the restic repository password) are encrypted
with AES-256-GCM. The key is derived with HKDF from a dedicated
STORAGE_KEY, independent of the JWT signing secret, so leaking one does not expose the other. - The restic password is passed to restic via a private file, never on the command line or in logs. Secrets are never returned in API responses.
Command execution and file access
- Every call to
docker,docker compose, andgitis made with an explicit argument vector, not a shell string, so stack names, container names, and file paths cannot inject commands. - Reads and writes to compose files,
.envfiles, and backup targets are confined to the configured stacks and data directories. Containment is symlink-aware, so a symlink inside a stack directory cannot redirect a write onto a host file outside it.
Web layer
- State-changing requests require a CSRF token (double-submit cookie + header).
- CORS uses an exact-match allowlist; credentialed requests are never paired with a wildcard origin.
- WebSocket endpoints (including the terminal) validate the request
Origin, so the shell cannot be driven from another site (cross-site WebSocket hijacking). - Responses carry a Content-Security-Policy with
frame-ancestors 'none', plusX-Frame-Options,X-Content-Type-Options, andReferrer-Policy. Generic error responses avoid leaking stack traces or internals.
Dependencies and build
- Go and npm dependencies are scanned (
govulncheck,pnpm audit) and kept current; the binary is built with a patched Go toolchain and base images are pinned.
The honest boundary. None of this changes the fact that access to the Docker socket is root-equivalent control of the host (see Docker Socket & Security above). Treat a Capstan login as administrative access, run it on a trusted network behind TLS, and use a socket proxy if you need least-privilege. The application hardening above reduces the ways that trust can be abused; it is not a substitute for protecting access to Capstan itself.
capstan/
├── backend/ # Go backend (Gin API, SQLite, Docker/Git services)
│ ├── cmd/server/ # main entrypoint
│ └── internal/ # handlers, services, middleware, models
├── frontend/ # React + Vite + Tailwind SPA
│ └── src/ # components, hooks, stores, lib
├── docker/ # production Dockerfile + compose
├── docker-compose.yaml # local dev (builds the all-in-one image)
└── docker-compose.prod.yaml # runs the published image
# Full stack (Docker)
./start-local.sh # build + start
docker compose logs -f # view logs
docker compose down # stop
# Backend only
cd backend
./run-local.sh # quick start
make run # run the server
make test # run tests
# Frontend only
cd frontend
./run-dev.sh # Vite dev server (:5173)
npm run build # production buildAll routes are under /api/v1 and require authentication (unless
AUTH_DISABLED=true), except /health. The web UI is the primary interface;
these are the main REST routes.
GET /health— health check (restricted to localhost)
POST /api/v1/auth/setup— create the first admin (only when no user exists)POST /api/v1/auth/login— log inPOST /api/v1/auth/logout— log outGET /api/v1/auth/me— current user
GET /api/v1/stacks— list stacksPOST /api/v1/stacks— create a stackGET /api/v1/stacks/:id— stack detailsPOST /api/v1/stacks/:id/start·/stop·/restart·/pull— lifecycle actionsDELETE /api/v1/stacks/:id— delete a stack
GET/PUT /api/v1/stacks/:id/compose— read/write compose filePOST /api/v1/stacks/:id/compose/lint— lint compose fileGET/PUT /api/v1/stacks/:id/env— read/write.envfile
GET /api/v1/git?stackId=<id>— statusPOST /api/v1/git/pull— pull changesGET /api/v1/git/log— commit logGET /api/v1/git/diff/:hash— commit diff
POST /api/v1/backups/run·/sync·/restore·/dr-restore·/pruneGET /api/v1/backups/status·/history·/snapshots
Capstan reads the same on-disk stack layout as Dockge, so migration is mostly a matter of pointing it at your existing stacks directory.
-
Back up existing stacks:
cp -r /opt/stacks /opt/stacks.backup
-
Map the stacks directory (Dockge uses
DOCKGE_STACKS_DIR; Capstan usesSTACKS_DIR, and requiresHOST_STACKS_DIRto match — see Volume Path Identity):environment: - STACKS_DIR=/opt/stacks - HOST_STACKS_DIR=/opt/stacks
-
Start Capstan and create an admin account on first run (
/auth/setup). Accounts are not migrated from Dockge. -
Verify path validation:
docker compose logs | grep "Volume path identity"
-
Test with a single stack before relying on it for production.
You can run Dockge and Capstan side by side during migration as long as only one manages a given stack at a time.
# Generate secrets (use two distinct values)
JWT_SECRET=$(openssl rand -hex 32)
STORAGE_KEY=$(openssl rand -hex 32)
# Create production .env file
cat > .env << EOF
PORT=5001
LOG_LEVEL=info
JWT_SECRET=$JWT_SECRET
STORAGE_KEY=$STORAGE_KEY
AUTH_DISABLED=false
STACKS_DIR=/opt/stacks
HOST_STACKS_DIR=/opt/stacks
DATA_DIR=/app/data
TRUSTED_NETWORKS=172.16.0.0/12,10.0.0.0/8,192.168.0.0/16,127.0.0.1
EOF
docker compose -f docker-compose.prod.yaml up -dSTORAGE_KEY encrypts stored secrets (git tokens, restic password) at rest with
a key independent of JWT_SECRET; if unset it falls back to JWT_SECRET. Using a
separate value means rotating JWT_SECRET doesn't require re-encryption and a
leaked JWT_SECRET alone can't decrypt stored secrets.
- Set a strong
JWT_SECRET(min 32 characters) and a separateSTORAGE_KEY. - Keep
AUTH_DISABLED=falsefor anything reachable off localhost. - Terminate TLS at a reverse proxy (nginx, Traefik, Caddy) and forward
X-Forwarded-Proto: https— Capstan uses it to setSecurecookies and HSTS. - Configure
TRUSTED_NETWORKSfor access control. - Set up and test backups (Settings → Backup).
- For least-privilege Docker access, front the socket with a proxy (see Docker Socket & Security).
Upgrading: this release binds JWTs to an issuer claim, so existing sessions are invalidated on upgrade — log in again once. Previously stored secrets stay readable and are re-encrypted under the new key scheme on next save.
See TESTING.md for local testing and development workflow. Example
environment files: .env.example (production) and
backend/.env.example (local dev).
- Language: Go 1.25
- Database: SQLite
- Framework: Gin
- Docker SDK: docker/docker (Moby) client
- Git library: go-git
- Language: TypeScript
- Framework: React + Vite
- UI: Tailwind CSS
- State: TanStack Query
- Editor: CodeMirror 6
Capstan follows Semantic Versioning (MAJOR.MINOR.PATCH),
published as git tags prefixed with v (e.g. v0.1.0).
While in the 0.x range, Capstan is pre-stable: it offers no
backward-compatibility guarantees yet. During this phase, treat a MINOR bump
(0.1.x → 0.2.0) as potentially breaking and a PATCH bump (0.1.0 →
0.1.1) as fixes only. The first stable release will be v1.0.0, after which
standard SemVer rules apply (MAJOR for breaking changes).
Each release tag publishes the matching container image tags:
| Git tag | Image tags |
|---|---|
v0.11.0 |
:0.11.0, :0.11, :latest |
v0.12.0-rc.1 |
:0.12.0-rc.1 (pre-release; not :latest) |
Pin a specific version (e.g. ghcr.io/thinkbig1979/capstan:0.11.0) for
reproducible deployments; :latest always tracks the most recent stable
release.
MIT — see LICENSE.