- Rust 72.9%
- HTML 23.1%
- Dockerfile 3.4%
- Shell 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| go | ||
| node | ||
| php | ||
| python | ||
| src | ||
| static | ||
| templates | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| install.sh | ||
| LICENSE | ||
| README.md | ||
deploy-stack
Universal Docker deployment platform with web UI, API, multi-node cluster management, and Forgejo CI/CD runners.
v2.0.0 — Rust rewrite. The core is now a single static Rust binary that is byte-compatible with the original Go v1.5.3 HTTP responses (same headers, status codes, content-types and JSON shapes), and adds the
PORT=8282:8080external:internal port syntax. See Known deviations.
Zero-config deploy: in a workflow it is enough to specify runs-on — the
rest deploy-stack does itself: it detects the language, builds the container,
and brings the site up.
name: Deploy
on: { push: { branches: [main] } }
jobs:
deploy:
runs-on: my-runner
steps:
- uses: actions/checkout@v4
- run: deploy
Port, name and type are set (optionally) from a .deploy file in the repo
root — see .deploy file.
Features
Web UI (v2.0.0)
| Tab | What you get |
|---|---|
| Sites | All compose sites: port, status, open ↗, term, restart/stop/rm, search, auto-refresh |
| Containers | All Docker containers: image, ports, term, inspect, start/stop/restart/rm |
| Cluster | Nodes — cards with sites/containers/runners counts, online/offline, manage/ping/edit/rm. All sites — every site from every node in one list with full control (open↗ by node host, term, stop/restart/start, rm, manage) |
| Runners | Forgejo/Gitea/GitHub runners: labels, term, edit, restart/stop/rm |
| Settings | Accent color, themes (dark/light/black), show/hide all containers |
Terminal (everywhere): the term button opens a modal with logs plus a
command input line. It works on the main node and on any cluster node (exec is
streamed in real time). There are ↻ logs and clear.
Deploy to node: the deploy form lets you pick a target cluster node.
Toasts instead of alerts; Sites/Containers/Runners/Nodes counters in the top bar.
Engine
- Zero-config deploy —
deployin CI: name from the repository, language auto-detected, port fromPORT/.deploy - Auto-detection — PHP, Python (Django/Flask/FastAPI), Node.js/TypeScript, Bun, Deno, Go, Rust, Java/Kotlin, Ruby, Elixir, Haskell, Lua, Zig, Nim, Swift, C/C++, static sites
- CLI —
deploy(auto) /add/rm/list/logs/restart/runners/add-runner/rm-runner/status/up/down/serve - Cluster API — aggregate status, remote exec/logs (streaming), remote deploy, node CRUD
Architecture
Host
├── Forgejo (git.example.com) + Actions
├── node-a :9090 + N sites + runner label: a
├── node-b :9090 + N sites + runner label: b
└── node-c :9090 + N sites + runner label: c
↑
All nodes join one Cluster (manage from any panel)
Each node runs the same panel (Docker or host binary). Any panel can act as the "main" UI and remotely manage the others.
Quick Start
1. Build
deploy-stack is written in Rust (a byte-compatible rewrite of the original Go v1.5.3, now v2.0.0). Building needs a recent stable Rust toolchain.
git clone https://git.example.com/example/deploy-stack.git
cd deploy-stack
cargo build --release -j2 # binary: target/release/deploy-stack
Note: keep the parallelism low (
-j2) on shared/constrained hosts — a largecargo build -jNcan saturate CPU and disk.
Or with Docker (no local Rust needed):
docker build --network=host -t deploy-stack:latest .
2. Install on a server
scp deploy root@SERVER:/root/deploy-stack/deploy
scp -r static/ templates/ root@SERVER:/root/deploy-stack/
mkdir -p /root/deploy-stack/data
/root/deploy-stack/deploy serve --port 9090
static/ serves the web UI; templates/ is required to build site images
(it is looked up next to the binary).
3. Or docker-compose
git clone https://git.example.com/example/deploy-stack.git
cd deploy-stack
./install.sh # .env with random password + up
# or
cp .env.example .env # set DEPLOY_ADMIN_PASS
docker compose up -d --build
# panel on :3000
IPv6: if host IPv6 is broken,
docker buildmay hang onapk update. Usedocker build --network=host -t deploy-stack:latest .or set in/etc/docker/daemon.json:{ "dns": ["1.1.1.1","8.8.8.8"], "ipv6": false }
4. First login
Open http://SERVER:PORT and sign in with the credentials set in .env
(DEPLOY_ADMIN_USER / DEPLOY_ADMIN_PASS). install.sh generates a random
password for you.
CLI
deploy # auto-deploy current repo (CI)
deploy --port 8084 [--name N --type T] # auto-deploy with flags
deploy add <name> <dir> [--port N[:M]] [--type T] # add site (auto language)
deploy rm <name> # remove site
deploy list # sites: port + status
deploy logs <name> [--tail N] # site logs
deploy restart <name> # restart site
deploy runners # list runners
deploy add-runner <name> <token> [--url URL] # add Forgejo runner
deploy rm-runner [name] # remove runner(s); all if no name
deploy status # site containers
deploy up / down # start/stop all
deploy serve --port 3000 # panel (web UI + API)
Auto-deploy flags (CI)
Priority: flag > env > .deploy file > auto-detect.
| Flag | Env | Meaning |
|---|---|---|
--port N[:M] |
PORT / DEPLOY_PORT |
host port (else .deploy PORT, else a free port). N:M sets host N + container M |
--name N |
DEPLOY_SITE |
site name (else NAME / repo name / dir name) |
--type T |
DEPLOY_TYPE |
static|php|python|node|go (else TYPE / auto-detect) |
--dir D |
DEPLOY_DIR |
source dir (else GITHUB_WORKSPACE) |
| — | DEPLOY_BIND |
address the container publishes on (default 0.0.0.0) |
| — | DEPLOY_HOST |
IP shown in the "deployed" URL (default 0.0.0.0) |
Port syntax N:M: a port value may be a single number (8282 — host port,
container port = language default) or external:internal (8282:8080 — host
8282, container 8080). This works everywhere a port is accepted: the
.deploy file, the PORT/DEPLOY_PORT env vars, the --port flag, the
interactive prompt, and the API port field (JSON number or string).
Only --dir, --name, --type and --port are recognized as flags;
DEPLOY_BIND and DEPLOY_HOST are environment-only (there is no --bind /
--host flag).
Tools, container env and extra volumes are configured per site in the
panel — the cfg button in the Sites tab, or
GET/POST /api/v1/sites/:name/custom (stored in site-custom.json).
There are no --tools / --env / --volumes flags and no
DEPLOY_TOOLS / DEPLOY_ENV / DEPLOY_VOLUMES variables.
.deploy file (optional)
Put it in the repo root — then the workflow only needs runs-on:
NAME=my-site
PORT=8084 # host port only (container = language default)
# PORT=8282:8080 # or external:internal (host 8282, container 8080)
TYPE=static # static | php | python | node | go
# extra sync exclusions (space-separated tokens)
SYNC_EXCLUDE=src/ Cargo.toml Cargo.lock Dockerfile *.md .forgejo/
NAME, TYPE, PORT and SYNC_EXCLUDE are read from this file. PORT
accepts both the plain form (8084) and the external:internal form
(8282:8080).
SYNC_EXCLUDE
When CI deploys, the repo is synced to /var/www/<NAME> with
rsync -a --delete (or the built-in copyTree when rsync is missing).
SYNC_EXCLUDE adds project-specific exclusions on top of the built-in list:
- each whitespace-separated token becomes its own
--exclude=<token>(passed verbatim — no glob expansion, no normalization); - no
SYNC_EXCLUDEkey → behavior is exactly as before; - the same tokens are honored by the
copyTreefallback; --delete-excludedis not used — files already in the destination that match the pattern are left alone.
Per-site extra volumes (secrets/keys)
site-volumes.conf next to the binary (or DEPLOY_SITE_VOLUMES):
<service>=<host-path>:<container-path>[:ro][,...]
Example:
panel=/etc/ssh/reverse.key:/etc/deploy-secrets/reverse.key:ro
PHP container copies /etc/deploy-secrets/* → /etc/ssh/ as www-data:www-data 0600
at start, so ssh/rsync from PHP work. File is gitignored.
Volumes saved through the panel's per-site customization are appended on top of this list.
API
Authenticated endpoints accept the token either as
Authorization: Bearer <token> or via the dt cookie set at login.
/api/v1/login and /health need no auth.
Auth & status
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/login |
Get token (also sets the dt cookie) |
| POST | /api/v1/logout |
Invalidate token |
| GET | /api/v1/status |
Server status (containers/sites/runners/version) |
| GET | /health |
Liveness (no auth) |
| GET | /api/v1/docs |
Built-in API docs (HTML) |
Sites & containers
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/sites |
Managed sites (name/port/status/image) |
| POST | /api/v1/sites |
Deploy site |
| POST | /api/v1/deploy |
One-shot auto deploy; body {"name":"...","dir":"...","port":N,"type":"..."} (name or dir required) |
| GET | /api/v1/sites/:name/custom |
Site customization (tools/env/volumes) |
| POST | /api/v1/sites/:name/custom |
Save customization; body {"tools":[],"env":{},"volumes":[],"apply":true} redeploys |
| GET | /api/v1/containers |
List containers (?show_all=true) |
| GET | /api/v1/containers/:name/logs |
Logs (?tail=N) |
| POST | /api/v1/containers/:name/exec |
Streaming exec — body {"cmd":"..."} |
| GET | /api/v1/containers/:name/inspect |
Inspect JSON |
| POST | /api/v1/containers/:name/start|stop|restart |
Lifecycle |
| DELETE | /api/v1/containers/:name/remove |
Remove container |
Runners
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/runners |
List runners |
| POST | /api/v1/runners/add |
Add runner |
| POST | /api/v1/runners/:name/edit |
Edit labels |
| GET | /api/v1/runners/:name/logs |
Runner logs |
| POST | /api/v1/runners/:name/start|stop|restart |
Lifecycle |
| DELETE | /api/v1/runners/:name |
Remove runner |
Cluster
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/cluster/status |
Aggregate all nodes (parallel) |
| GET | /api/v1/servers |
List nodes |
| POST | /api/v1/servers |
Add node |
| PUT | /api/v1/servers/:id |
Update node (name/host/port/user/pass) |
| DELETE | /api/v1/servers/:id |
Remove node |
| GET | /api/v1/servers/:id/ping |
Ping one node |
| GET | /api/v1/servers/:id/status |
Remote status |
| GET | /api/v1/servers/:id/sites |
Remote sites |
| POST | /api/v1/servers/:id/deploy |
Deploy on remote node |
| GET | /api/v1/servers/:id/containers |
Remote containers |
| GET | /api/v1/servers/:id/containers/:name/logs |
Remote logs (streamed) |
| POST | /api/v1/servers/:id/containers/:name/exec |
Remote streaming exec |
| GET | /api/v1/servers/:id/sites/:name/logs |
Remote site logs (streamed) |
| GET | /api/v1/servers/:id/runners |
Remote runners |
| GET | /api/v1/servers/:id/runners/:name/logs |
Remote runner logs (streamed) |
| POST/DELETE | /api/v1/servers/:id/containers/:name/... |
Remote lifecycle proxy |
Examples
# login → {"status":"ok","token":"<token>"}
TOKEN=$(curl -sX POST http://localhost:9090/api/v1/login \
-H "Content-Type: application/json" \
-d '{"username":"'"$DEPLOY_ADMIN_USER"'","password":"'"$DEPLOY_ADMIN_PASS"'"}' \
| sed -E 's/.*"token":"([^"]+)".*/\1/')
# exec
curl -N -X POST http://localhost:9090/api/v1/containers/my-site-php/exec \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"cmd":"ls /var/www"}'
# cluster aggregate
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:9090/api/v1/cluster/status
Runner Labels
Labels are free-form, comma-separated strings on a runner. A workflow runs on
a runner whose labels include its runs-on value.
| Label | Node |
|---|---|
web |
web node |
app |
app node |
ci |
build node |
Workflow example
name: Deploy
on: { push: { branches: [main] } }
jobs:
deploy:
runs-on: web
steps:
- uses: actions/checkout@v4
- run: deploy
Port, name and type live in the .deploy file (see above) — no env: block
needed. For PHP sites needing their own webserver set TYPE=php there
(nginx + php-fpm in one container). For other languages a repo-level
Dockerfile is used as-is when no extra tools are configured in the panel
(PHP always uses the built-in template; static sites always use nginx:alpine).
Forgejo Runner Setup
cd /root/deploy-stack
./deploy add-runner my-runner <REGISTRATION_TOKEN> --url http://forgejo:3000
--url is the Forgejo/Gitea server URL (default http://10.0.0.1:3000).
Runner comes up in a container with docker-cli/rsync/node, links deploy,
and uses the given labels. Runner is also in docker-compose.yml (profile ci)
and mounts /var/run/docker.sock, ./www, ./deploy-data.
Cluster Management
- Cluster tab → + Node
- Name, Host (IP), API Port, Login, Password → Connect
- Node card: manage / ping / edit / rm
- manage → Containers / Sites / Runners / System — full control (term, start/stop/restart/rm, open↗)
- All sites — every site on every node with the same controls; open↗ uses the node's host, not the panel host
Remote exec/logs are streamed (live output in Terminal).
Updating
cd /root/deploy-stack
git pull
docker compose up -d --build # if compose
# or host binary:
cargo build --release -j2 && cp target/release/deploy-stack /root/deploy-stack/deploy && systemctl restart deploy-stack
Note: the panel runs the host-mounted binary (
/root/deploy-stack/deploy-data/deploy), not the one inside the image. When updating, replace that binary (andstatic/index.htmlif the UI changed), thendocker restart deploy-stack. Never touch site containers or.env.
Known deviations (Rust rewrite)
The Rust core targets byte-level parity with the Go v1.5.3 HTTP output. The intentional and accepted differences are:
- Version —
2.0.0(Go was1.5.3). - Port syntax — new
external:internal(8282:8080) form forPORT,.deploy,--port, the prompt and the APIportfield. - Cluster
GET /servers/:id/containers/:cname/logs— Go'sServeMuxlongest-prefix matching lets the/servers/:id/containers/:cnamesubtree handler shadow the dedicated.../logsroute, so that route was unreachable in Go; the Rust router resolves it explicitly (logs are streamed). - Path cleaning — Go's
ServeMuxissues301redirects for unclean paths (e.g.//,/../); the Rust router matches the raw path and does not redirect. - HTTP/1.0 +
Connection: keep-alive+ large body — Go closes the connection (close-delimited body);tiny_httpmay keep it open until the client closes. Only affects this rare combination. - JSON decode strictness — a few endpoints differ on malformed JSON
(trailing garbage / partial decode) due to
serdevs Go's streaming decoder; valid JSON is identical. - Sticky ports — Go called
freePorton every deploy without an explicitPORT, so a re-deploy could silently move the site to a new port. The Rust build reuses the already-bound port (compose entry first, running container second) unless an explicit port is given, and/containers/:name/editpersists the new binding back intosites/docker-compose.ymlso the edit survives the next re-deploy.
Supported Languages (auto-detected)
| Language | Base image | Internal port |
|---|---|---|
| Static HTML | nginx:alpine | 80 |
| PHP 8.2 | nginx + php-fpm | 80 |
| Python 3.12 / Django / Flask / FastAPI | python:3.12-alpine | 8000 |
| Node.js 20 | node:20-alpine | 3000 |
| Bun | oven/bun:alpine | 3000 |
| Deno | denoland/deno:alpine | 8000 |
| Go | golang:1.22-alpine | 8080 |
| Rust | rust:1.77-alpine | 8080 |
| Java/Kotlin | eclipse-temurin:21 | 8080 |
| Ruby 3.3 | ruby:3.3-alpine | 3000 |
| Elixir 1.16 | elixir:1.16-alpine | 4000 |
| Haskell 9.6 | haskell:9.6-alpine | 8080 |
| Lua 5.4 | lua:5.4-alpine | 8080 |
| Zig 0.11 | ziglang/zig:0.11-alpine | 8080 |
| Nim | nimlang/nim:alpine | 8080 |
| Swift 5.10 | swift:5.10-alpine | 8080 |
| C/C++ | gcc:alpine | 8080 |
A .NET template also ships in templates/dotnet/, but .NET is not wired
into auto-detection or --type yet.
License
MIT