You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Dockify is a self-hosted Docker app deployment platform — single Go binary, SQLite database, SSH-based worker management, embedded Web UI (Go html/template + HTMX), Caddy reverse proxy integration, and Cloudflare DNS automation.
Tech Stack
Layer
Choice
Details
Language
Go 1.26+
Single binary, cross-compile
Database
SQLite via modernc.org/sqlite
Pure Go, no CGo, embedded
Router
github.com/go-chi/chi/v5
Middleware, groups, path params
SSH
golang.org/x/crypto/ssh
Std SSH client, PTY sessions
Web UI
Go html/template + HTMX
Embedded via embed (templates and the vendored htmx/xterm files, no CDN), no JS framework, no build step
CSS
Fully custom (no framework)
CSS variables, dark/light mode, single <style> block
Terminal
xterm.js + WebSocket
Interactive SSH console in browser
Docker
Multi-stage build
Published to ghcr.io/coderbuzz/dockify
Project Structure
cmd/dockify/main.go # Entry point: config → db → services → router, HTTP server
internal/
config/config.go # Environment variable config
db/db.go + schema.sql # SQLite (one connection, WAL, foreign_keys=ON), 12 tables; ad-hoc migrations run on every start
model/model.go # Shared view types (AppSummary, ChartPoint)
ssh/
interface.go # Connector interface + Factory (a new SSH connection per remote action)
client.go # SSH client: exec wrapped in `timeout -k 10 N sh -c`, pipes, stdin file writes, PTY, TOFU host keys, key generation
mock.go # Mock SSH for dev mode (DOCKIFY_DEV_MOCK=true)
server/
model.go # Server, ServerStats
repository.go # servers + server_stats queries
service.go # Server CRUD, worker init and prune, resource refresh, Monitor (every 10s)
handler.go # JSON API handlers + web UI handlers (pages, forms, HTMX cards)
app/
model.go # App, Deployment, stats types
repository.go # apps, deployments, routes, secrets, files and stats queries
service.go # Deploy/undeploy/redeploy/rollback/stop/start, move between workers, Caddy route + DNS per domain
compose.go # Simple-mode compose generation, dockify network injection, service rename
stats.go # Stats collector (containers 10s, Caddy traffic 30s, disk 5 min, prune hourly) + history
handler.go # JSON API + web UI handlers (apps, deployments, secrets, files, logs, stats)
scheduler/scheduler.go # Auto-select least-loaded server
caddy/client.go # Caddy Admin API client: route CRUD and config save via `docker exec caddy curl` over SSH
cloudflare/client.go # Cloudflare DNS API v4 (list, create, update, delete records)
webhook/handler.go # GitHub + GitLab webhook receiver, HMAC/token validation
settings/
settings.go # Webhook secret, GitHub Releases update check, systemd-run self-update
handler.go # Settings page, webhook secret get/roll, update check/run
backup/
backup.go # YAML export/import, AES-GCM encryption with PBKDF2 passphrase
handler.go # Export/import pages and endpoints
http/
router.go # Chi router, all routes, middleware
auth.go # Session auth, login/logout, bearer token, cross-site request check
console.go # WebSocket upgrade, PTY relay for SSH and container consoles
stats_ws.go # WebSocket real-time server stats (1s interval)
app_stats_ws.go # WebSocket real-time app container stats
templates.go # Embedded templates, template funcs, Render (into a buffer, then write)
static/ # app.css (tokens + components), app.js, list.js, charts.js, console.js; vendored htmx 2.0.4, xterm 5.3.0, xterm-addon-fit 0.8.0 (SOURCES.md: URLs + sha256)
templates/ # HTML templates (pages, layout, partials)
layout.html # head/nav/scripts blocks (CSS lives in static/app.css)
ui_*.html # Shared partials: ui_icon, ui_sheet, ui_confirm, ui_alert, ui_segmented, ui_listbar
dashboard.html # Dashboard with stats cards + server/app summary tables
servers.html # Server list page (servers_list.html: table partial)
servers_add.html # Add server form
servers_detail.html # Server detail: info, resources card, live charts, SSH console, actions
servers_edit.html # Edit server form
servers_resources_card.html# HTMX partial: resource usage with human-readable values
servers_stats_card.html # HTMX partial: server stats
apps.html # App list page (apps_list.html: table partial)
apps_add.html # Deploy app form, also used for edit (simple/advanced toggle)
apps_detail.html # App detail: info, compose, env vars, files, logs, deployments, live charts, console
apps_stats_card.html # HTMX partial: app stats
settings.html # Settings: webhook secret, export/import links
about.html # About page: version, update check/run, sponsor link
export.html # Export page: passphrase generator, download YAML
import.html # Import page: file upload, mode selector (merge/replace)
login.html # Login page
error.html # Error page
scripts/
install.sh # One-liner install (3 modes: Docker Compose / Binary / Binary + Caddy)
setup-worker.sh # setup-worker.sh '<public key>': authorize the controller key, install Docker, prepare /opt/dockify
gen-ssh-key.sh # Dedicated SSH key for external/agent access to a VM
update.sh # Auto-detect install mode, download & restart latest
update-latest.sh # Same, run directly on the host
release.sh # Version bump + tag helper
smoke-test.sh # Build, start, probe /health, /login and the auth redirect
Dockerfile # Multi-stage Docker build
docker-compose.yml # Dockify + Caddy reverse proxy (mode 1)
.air.toml # Air live-reload config
.github/workflows/build.yml # CI on PRs, main and v* tags: vet, gofmt -l, test -race, govulncheck, smoke test; v* tag also builds binary + .sha256 + Docker, releases
Environment Variables
Variable
Default
Required
Description
DOCKIFY_HOST
0.0.0.0
No
Network interface to bind
DOCKIFY_PORT
8080
No
HTTP port
DOCKIFY_DATA_DIR
/var/lib/dockify
No
SQLite DB + SSH keys storage
DOCKIFY_SSH_KEY_DIR
$DATA_DIR/keys
No
Per-server SSH private key files
DOMAIN
—
Mode 1
Domain for Caddy reverse proxy (auto HTTPS)
DOCKIFY_ADMIN_USER
admin
No
Web UI login username
DOCKIFY_ADMIN_PASSWORD
—
Unless loopback
Web UI password. Not set = no authentication, and serve exits unless DOCKIFY_HOST is loopback or DOCKIFY_ALLOW_NO_AUTH=true
DOCKIFY_ALLOW_NO_AUTH
false
No
Allow a passwordless UI on a non-loopback bind
DOCKIFY_API_TOKEN
—
No
Authorization: Bearer token accepted on all protected routes (scripts/agents)
DOCKIFY_DEV_MOCK
false
No
Enable mock SSH client for local development
DOCKIFY_BASE_PATH
—
No
URL prefix when behind reverse proxy (e.g. /proxy/9898); X-Forwarded-Prefix overrides it per request. Templates use links relative to <base href>, redirects go through web.Redirect, which prepends the prefix
DOCKIFY_TRUST_PROXY
false
No
Apply chimw.RealIP (client IP from X-Forwarded-For/X-Real-IP); off = TCP peer address
CLOUDFLARE_API_TOKEN
—
No
Cloudflare API token for DNS automation (Zone:DNS:Edit scope)
CLOUDFLARE_ZONE_ID
—
No
Cloudflare zone ID for the domain
Database Schema (12 Tables)
Foreign keys are enforced (foreign_keys=ON). ON DELETE CASCADE exists only on app_secrets, app_files, the stats tables' app_id and server_stats.server_id.
servers
Column
Type
Description
id
INTEGER PK
Auto-increment
name
TEXT NOT NULL
Display name
host
TEXT NOT NULL
IP or hostname
port
INTEGER DEFAULT 22
SSH port
user
TEXT DEFAULT 'root'
SSH user
ssh_key
TEXT NOT NULL
Path to the private key file (0600, $DOCKIFY_SSH_KEY_DIR/<id>.pem)
status
TEXT DEFAULT 'pending'
pending, online, offline, error, initializing
cpu_cores
INTEGER
Total CPU cores
ram_mb
INTEGER
Total RAM in MB
disk_gb
INTEGER
Total disk in GB
cpu_usage
REAL
CPU usage % (0.0 - 100.0)
ram_usage
REAL
RAM usage % (0.0 - 100.0)
disk_usage
REAL
Disk usage % (0.0 - 100.0)
resources_updated_at
DATETIME
Last resource refresh timestamp
registry_host
TEXT DEFAULT ''
Registry for docker login (empty → ghcr.io)
registry_user
TEXT DEFAULT ''
Registry username (empty = no credential)
registry_token
TEXT DEFAULT ''
Path to registry token file (0600, $DOCKIFY_SSH_KEY_DIR/<id>.registry-token); never serialized to the API
host_key
TEXT DEFAULT ''
Pinned SSH host key (base64 wire format), learned on first connect; empty = not pinned yet; never serialized to the API
created_at
DATETIME
Creation timestamp
updated_at
DATETIME
Last update timestamp
apps
Column
Type
Description
id
INTEGER PK
Auto-increment
name
TEXT NOT NULL
Display name
server_id
INTEGER FK→servers
Target worker VM
domain
TEXT DEFAULT ''
Public domain (Caddy + Cloudflare)
port
INTEGER DEFAULT 0
Internal container port
compose
TEXT NOT NULL
docker-compose.yml content
git_repo
TEXT
Git repo URL for webhook CI/CD
git_branch
TEXT DEFAULT 'main'
Git branch to track
auth_user
TEXT DEFAULT ''
HTTP basic auth username
auth_pass
TEXT DEFAULT ''
HTTP basic auth password (plaintext; bcrypt-hashed when the Caddy route is built)
Vendored htmx/xterm assets embedded in the binary (internal/http/static/, sources in SOURCES.md); one-week cache, no directory listing
Protected routes accept the session cookie or Authorization: Bearer $DOCKIFY_API_TOKEN. A POST/PATCH/DELETE sent by another site (Sec-Fetch-Site, or Origin ≠ Host for older browsers) gets 403; bearer-token requests are exempt. WebSocket handshakes must be same-origin. Request bodies under /api/servers and /api/apps are capped at 1 MiB.
Protected — Server API (JSON)
Method
Path
Description
GET
/api/servers
List servers
POST
/api/servers
Create server {name, host, port?, user?, ssh_key?, registry_*?}; without ssh_key an ed25519 key pair is generated on the controller (the worker is then not initialized automatically). Response includes public_key
GET
/api/servers/:id
Get server details, including public_key (authorized_keys line of the server's key)
PATCH
/api/servers/:id
Partial update (host, port, user, ssh_key, registry_host, registry_user, registry_token, registry_clear, host_key_clear); a host or port change also clears the pinned host key
DELETE
/api/servers/:id
Delete server
POST
/api/servers/:id/init
Initialize worker (Docker + Caddy)
POST
/api/servers/:id/refresh
Refresh resource metrics
POST
/api/servers/:id/prune
docker system prune on the worker {all?, volumes?} + builder prune
GET
/api/servers/:id/console
WebSocket upgrade → SSH terminal
GET
/api/servers/:id/stats/history
Stats history (?range=realtime|1h|6h|24h|7d, default 1h)
GET
/api/servers/:id/stats/live
WebSocket real-time resource stats (1s interval)
Protected — Server Web UI
Method
Path
Description
GET
/servers
Server list page
GET
/servers/add
Add server form
POST
/servers/add
Submit add server form
GET
/servers/:id
Server detail page
GET
/servers/:id/edit
Edit server form
POST
/servers/:id/edit
Submit edit server form
POST
/servers/:id/init
Initialize worker (web form)
POST
/servers/:id/prune
Prune worker (web form)
GET
/servers/:id/resources
Resource card (HTMX partial)
GET
/servers/:id/stats
Stats card (HTMX partial)
POST
/servers/:id/refresh
Refresh resources (web form)
POST
/servers/:id/delete
Delete server (web form)
DELETE
/servers/:id
Delete server
Protected — App API (JSON)
Method
Path
Description
GET
/api/apps
List apps
POST
/api/apps
Create and deploy app
GET
/api/apps/:id
Get app details
PATCH
/api/apps/:id
Partial update {compose?, server_id?, move_files?, purge_old?}; never touches env vars, files or domains; unknown fields are rejected; redeploys (or moves) a non-draft app
Set env var {key, value, is_secret?}; is_secret omitted keeps the stored flag (new keys: not secret); key must match [A-Za-z_][A-Za-z0-9_]*
DELETE
/api/apps/:id/secrets/:key
Delete secret
GET
/api/apps/:id/files
List config files
POST
/api/apps/:id/files
Set config file {path, content}; path validated (see Input validation)
DELETE
/api/apps/:id/files/*path
Delete config file (nested paths like conf/app.ini included)
GET
/api/apps/:id/console
WebSocket upgrade → shell in the app's first service container
GET
/api/apps/:id/stats
Latest container stats
GET
/api/apps/:id/stats/history
Container stats history (?range=, default 1h)
GET
/api/apps/:id/stats/live
WebSocket real-time container stats
GET
/api/apps/:id/traffic
Latest Caddy traffic per domain
GET
/api/apps/:id/traffic/history
Traffic history (?range=, default 1h)
GET
/api/deployments/:id
Get single deployment detail
Protected — App Web UI
Method
Path
Description
GET
/apps
App list page
GET
/apps/add
Deploy app form
POST
/apps/add
Submit deploy app form
POST
/apps/redeploy-all
Redeploy every running app
GET
/apps/:id
App detail page
POST/DELETE
/apps/:id/undeploy
Undeploy app (web form)
POST
/apps/:id/deploy
Deploy a draft app (web form)
POST
/apps/:id/clear-deployments
Clear deployment history (web form)
POST
/apps/:id/redeploy
Redeploy app (web form)
POST
/apps/:id/rollback
Rollback app (web form)
POST
/apps/:id/stop
Stop app (web form)
POST
/apps/:id/start
Start app (web form)
GET
/apps/:id/edit
Edit app form page
POST
/apps/:id/edit
Submit edit app form
GET
/apps/:id/stats
Stats card (HTMX partial)
Protected — Settings, Backup, Pages
Method
Path
Description
GET
/
Dashboard: stats, servers table, apps table
GET
/settings
Settings page
GET
/about
About page (version, sponsor link)
GET
/export
Export page (passphrase generator, download)
GET
/import
Import page (file upload, merge/replace)
GET
/api/settings/webhook-secret
Get webhook secret
POST
/api/settings/webhook-secret/roll
Regenerate webhook secret (it cannot be disabled)
GET
/api/settings/update/check
Check latest GitHub Release version (?force=1 skips the 5-minute cache)
POST
/api/settings/update/run
Trigger self-update: systemd-run --unit=dockify-upgrade /bin/bash -c fetching scripts/update.sh from the latest release tag (no temp file; refused when no tag is known); update.sh verifies the binary against the release's .sha256
Parse and validate compose (simple mode: generate compose from image + env vars + volumes)
Select target server (manual or auto-scheduler — server_id=0 = auto-select)
Ensure dockify Docker network exists on worker
SSH → write docker-compose.yml to /opt/dockify/apps/app-{id}/
Write app secrets as .env file to /opt/dockify/apps/app-{id}/.env (mode 0600, written on every deploy, empty when the app has no variables, so removed variables disappear). One KEY="value" line per variable: the value is trimmed, then \ → \\, " → \", newline → \n, CR → \r, $ → $$. Config files removed in the UI are not deleted from the worker
Write config files to /opt/dockify/apps/app-{id}/{path}
If the server has a registry credential: SSH → docker login <host> -u <user> --password-stdin with the token on stdin; on failure record registry login failed: … (token redacted) and stop
SSH → docker compose pull, then docker compose up -d (auto-detects docker-compose vs docker compose)
Inject Caddy route via Admin API (POST /config/apps/http/servers/srv0/routes)
If HTTP basic auth set: include bcrypt basic_auth handler in route
Persist Caddy's running config to the file named by its --config argument (read with docker inspect caddy: /etc/caddy/config.json on workers set up by Init, /data/config.json on older ones) so routes survive a Caddy restart
Create Cloudflare DNS A record (if configured, skips duplicates, upserts on IP change)
Save compose snapshot in deployment record (for rollback)
Record deployment with status, log, commit SHA (if Git-triggered)
Update app status → running
Undeploy runs docker compose down --volumes --rmi all for the app only; worker-wide pruning happens only through the Prune action.
Worker Setup
Add the server (UI or POST /api/servers) without a key: Dockify generates an ed25519 key pair, stores the private key as <id>.pem (0600) and shows the public key with the setup command on the server page. Pasting an existing private key remains possible (advanced).
On the worker, as the user Dockify connects as: curl -fsSL https://raw.githubusercontent.com/coderbuzz/dockify/main/scripts/setup-worker.sh | bash -s -- '<public key>'. The script appends the key to ~/.ssh/authorized_keys (600, no duplicates), installs Docker and the compose plugin, prepares /opt/dockify and prints the worker IP and user. Without an argument it prints usage and exits 1.
Click Init Worker.
SSH host keys (trust on first use)
Every SSH connection (deploys, monitor, console, live stats) goes through one factory that checks the worker's host key against servers.host_key (looked up by host and port). The first connection stores the presented key and logs pinned host key for host:port (SHA256:…); later connections fail with host key mismatch for host:port if the key differs. The server page shows the fingerprint (or "not pinned yet"). After reinstalling a worker, tick Forget pinned host key on the edit form or send host_key_clear: true; changing host or port clears it too.
Worker Init Flow
When a server is initialized (POST /api/servers/:id/init):
SSH connect + verify auth
Install Docker via get.docker.com (if not present)
Install Docker Compose plugin (if not present)
Create dockify Docker network
Check an existing caddy container (docker inspect caddy --format '{{json .Mounts}} {{json .Config.Labels}}'): it is Dockify-managed when a mount source is /opt/dockify/caddy (current layout) or /opt/dockify/caddy/config.json (old layout), or it has the label dockify.managed=true. Any other caddy container (running or stopped) → status error, init stops: remove or rename it first
Deploy Caddy container caddy:2 with --label dockify.managed=true (ports 80/443 + Admin API on localhost:2019); a running managed container is migrated to /opt/dockify/caddy:/etc/caddy when its admin API answers
Collect CPU cores (nproc), RAM total (/proc/meminfo), disk total (df -BG)
Collect CPU usage (/proc/stat), RAM usage (/proc/meminfo), disk usage (df -BG)
Status → online
Init is idempotent — re-running skips components that already exist (Caddy container, Docker, network).
Git Webhook Flow
Receive push event at POST /api/webhook/github or /api/webhook/gitlab
If not a push event → return 200 "ignored"
Validate HMAC-SHA256 (GitHub) or the token (GitLab, constant-time) against the global secret; reject 401 on mismatch or when the secret cannot be read
Query all apps (name, domain, port, compose, git_repo, git_branch, auth_user, auth_pass, compose_mode, memory_limit, cpu_limit, log_max_size, log_max_file, command, ports, ulimits_nofile, server name mapping) and domains: every active route's domain, the primary first
Query all app secrets and config files
If passphrase provided: encrypt SSH keys, registry tokens, secrets, auth_pass, and file contents with AES-GCM (PBKDF2 key derivation, 600,000 iterations, enc: prefix)
Generate YAML document
Download as dockify-config.yaml
Import
Parse YAML file
If passphrase provided: attempt decryption of all enc: values to validate passphrase (including SSH keys)
In merge mode (default): skip entries that already exist by same name
In replace mode: refuse with "stop or undeploy running apps before a replace import" unless every existing app is draft, created or stopped; otherwise undeploy all apps and delete all servers (the first error stops the import), then import
Create servers with SSH key from export (fallback to "pending" if key is empty — user must enter key manually)
Create apps (status = created, ready for deploy) with one route per entry in domains (exports without domains get a route for domain)
Import secrets and config files
Redirect to servers page
Auto-Scheduler Logic
When no server is specified for deployment (server_id=0):
app.Service.statsLoop(): container stats every 10s (one docker ps + one docker stats --no-stream per worker), Caddy traffic from /metrics every 30s, app folder disk usage every 5 min, prune of rows older than 7 days hourly.
Real-time WebSocket Stats (1s interval)
GET /api/servers/:id/stats/live — WebSocket endpoint that streams live CPU/RAM/disk metrics every 1 second. Uses the same /proc/stat and /proc/meminfo collection methods as the background collector. Client-side JS reconnects on disconnect.
UI Style Guide
The normative reference is docs/DESIGN-SYSTEM.md; the revamp plan and measurements are in docs/UI-REVAMP-PLAN.md and docs/ui-revamp/. Summary:
Stack: Go html/template + htmx + one custom stylesheet (internal/http/static/app.css), no CSS framework, no Node at build or runtime. Fonts: Plus Jakarta Sans (UI) and JetBrains Mono (technical values) from Google Fonts with display=swap.
Theme:<html data-palette="indigo|navy|teal" data-theme="light|dark">, chosen in Settings → Appearance (palette) and the header button or Settings (light / dark / system, default system). Preferences are in localStorage (dockify_palette, dockify_theme); a head script applies them before first paint. Components only use layer-2 tokens (--ink, --surface, --accent-fill, ...), never hex.
Shell: compact header (theme, sign out) plus a bottom tab bar below 900px and a tab row under the header from 900px; active item is set server-side (aria-current).
Components: shared partials (ui_icon, ui_sheet, ui_confirm, ui_alert, ui_segmented, ui_listbar) and classes (.btn-*, .card, .badge-*, .field, .rows tables that become card rows on phones). Dialogs are native <dialog>; there is no confirm() or alert().
Action hierarchy: one primary action per screen; dangerous actions (redeploy, rollback, init, prune, undeploy, remove, clear history, roll secret, update) are destructive-outline triggers, grouped in a "Danger zone" on detail pages, and always confirmed in an alert dialog.
Loading:xterm, charts, console and htmx are loaded only where used (xterm on first Console click).
Checks:node tests/e2e/ui.mjs (screenshots, overflow, DOM nodes, navigation timing, WCAG contrast, quick a11y); see AGENTS.md.
Security Considerations
Worker Caddy Admin API bound to 127.0.0.1:2019 — no external access
Controller ↔ Worker communication only via SSH (encrypted)
SSH private keys stored in DOCKIFY_DATA_DIR/keys/ with 0600 permissions; generated on the controller, only the public key is shown or returned by the API
SSH host keys pinned on first use (see Worker Setup)
Files are written to workers by piping the content to cat over SSH stdin with shell-quoted paths; no user content is parsed by the shell
Input validation at the trust boundary (API, web forms, backup import; 400 or a rejected import naming the value): domains are lowercased and trimmed, then must match ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$; config file paths are cleaned and must be relative, without .., made of [A-Za-z0-9._/-]; env keys must match ^[A-Za-z_][A-Za-z0-9_]*$. The simple-mode compose is generated with a YAML encoder, not string concatenation
Registry tokens stored the same way (<id>.registry-token, 0600); DB holds only the path. Sent to docker login --password-stdin over SSH stdin (never in the command line), redacted from deploy logs and errors, never returned by the API
Cloudflare API token requires minimal scope: Zone:DNS:Edit
App containers on internal dockify Docker network only — no public port exposure except via Caddy
Webhook authentication: HMAC-SHA256 (GitHub), token with constant-time compare (GitLab), auto-generated 64-char hex secret; always on (no disable), fails closed
Session auth: cookie-based (dockify_session, HttpOnly, SameSite=Lax, Secure when the request is TLS or X-Forwarded-Proto: https), 24-hour expiry, in-memory sessions; expired sessions are dropped on each login
Login: user and password compared in constant time; a failure waits 1 s; more than 10 failures from one client IP within 15 minutes (counted from the first) answer 429 until the window ends. The client IP is RemoteAddr; chimw.RealIP (trusting X-Forwarded-For/X-Real-IP) applies only with DOCKIFY_TRUST_PROXY=true
Cross-site requests: unsafe methods from another site get 403 (Go http.CrossOriginProtection), WebSocket handshakes must be same-origin, no CORS headers. A reverse proxy in front of Dockify must forward the original Host header (Caddy does by default)
Every response carries Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self' (inline scripts/styles are used in the templates), X-Content-Type-Options: nosniff and Referrer-Policy: same-origin. Browser scripts come from the binary, not a CDN; only Google Fonts is external