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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ Create a `.env` file in the project root or set these environment variables:
| `DOCKIFY_DEV_MOCK` | `false` | No | Enable mock SSH for local development (no real VMs needed) |
| `CLOUDFLARE_API_TOKEN` | — | No | Cloudflare API token for automatic DNS A records |
| `CLOUDFLARE_ZONE_ID` | — | No | Cloudflare zone ID |
| `DOCKIFY_BASE_PATH` | — | No | URL prefix when behind a reverse proxy (e.g. `/proxy/9898`) |
| `DOCKIFY_BASE_PATH` | — | No | URL prefix when behind a reverse proxy (e.g. `/proxy/9898`); links and redirects stay under it. A proxy can send `X-Forwarded-Prefix` per request instead |
| `DOCKIFY_TRUST_PROXY` | `false` | No | `true` takes the client IP from `X-Forwarded-For`/`X-Real-IP` (login throttle, logs). Set it only when every request comes through a proxy that sets these headers |

### Authentication

Expand All @@ -134,6 +135,7 @@ DOCKIFY_ADMIN_PASSWORD=secret123 docker compose up -d
- **Default username:** `admin` (configurable via `DOCKIFY_ADMIN_USER`)
- **No password set:** Dockify refuses to start unless it binds to loopback (`DOCKIFY_HOST=127.0.0.1`, `::1` or `localhost`) or `DOCKIFY_ALLOW_NO_AUTH=true` is set; in both cases the web UI has **no authentication**
- **Password set:** All routes (except `/health` and `/api/webhook/*`) require login
- **Failed logins:** each one waits 1 s; more than 10 from one client IP within 15 minutes get `429` until the 15 minutes are over. The client IP is the TCP peer unless `DOCKIFY_TRUST_PROXY=true`. Behind a reverse proxy (e.g. Caddy in front of `127.0.0.1:8080`) set `DOCKIFY_TRUST_PROXY=true` and keep the port unreachable from outside; otherwise every client counts as the proxy and failed logins from anyone lock everyone out for 15 minutes
- **Session:** Cookie-based (`HttpOnly`, `SameSite=Lax`, `Secure` behind HTTPS), expires after 24 hours
- **Cross-site requests:** a form or script on another site cannot change anything (`403`). Behind a reverse proxy, forward the original `Host` header (Caddy does this by default)
- **Logout:** Visit `/logout`
Expand Down Expand Up @@ -467,7 +469,7 @@ No DNS automation occurs if the env vars are not set.

## Backup & Restore

Export your configuration (servers + apps + secrets + config files) as YAML and import it into another instance.
Export your configuration (servers + apps with their resource limits, command, ports and every domain + secrets + config files) as YAML and import it into another instance.

### Encrypted Export

Expand All @@ -487,7 +489,7 @@ When importing on a new instance, all servers and their SSH keys are restored au
Upload a previously exported YAML file via the Import page (`/import`):

- **merge** (default): skip entries that already exist by name
- **replace**: delete all existing servers and apps before import
- **replace**: delete all existing servers and apps before import; refused unless every app is stopped, a draft or never deployed (stop or undeploy running apps first)
- Encrypted values are validated by attempting decryption before the actual import

### Unified Backup Page
Expand All @@ -512,6 +514,7 @@ Dockify's web UI is built with Go `html/template` + HTMX and fully custom CSS (n

- **Dark/Light mode** — toggle via nav button, persisted in `localStorage` (`dockify-theme`)
- **HTMX partial updates** — resource cards, log viewer, and status badges update without full page reloads
- **No CDN scripts** — htmx and xterm.js are embedded in the binary and served from `/static/`; a Content-Security-Policy limits scripts to Dockify itself (only Google Fonts is loaded from outside)
- **Responsive** — adapts to mobile screen sizes (single breakpoint at 600px)
- **Status badges** — color-coded for all server and app states
- **Confirm dialogs** — destructive actions (undeploy, delete, rollback) require confirmation
Expand Down
24 changes: 14 additions & 10 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Dockify is a self-hosted Docker app deployment platform — single Go binary, SQ
| 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`, no JS framework, no build step |
| 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` |
Expand Down Expand Up @@ -43,7 +43,7 @@ internal/
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, upsert records)
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
Expand All @@ -57,7 +57,8 @@ internal/
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
templates.go # Embedded templates, template funcs, Render (into a buffer, then write)
static/ # 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, all CSS (single <style> block)
dashboard.html # Dashboard with stats cards + server/app summary tables
Expand Down Expand Up @@ -106,7 +107,8 @@ docker-compose.yml # Dockify + Caddy reverse proxy (mode 1)
| `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`) |
| `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 |

Expand Down Expand Up @@ -254,7 +256,7 @@ Written by the background collectors, pruned after 7 days, `created_at` in UTC.
| POST | `/logout` | Clear session, redirect to login |
| POST | `/api/webhook/github` | GitHub push webhook (HMAC-SHA256), body ≤ 1 MiB |
| POST | `/api/webhook/gitlab` | GitLab push webhook (token), body ≤ 1 MiB |
| GET | `/static/*` | Static files (`web/static/`) |
| GET | `/static/*` | 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.

Expand Down Expand Up @@ -313,7 +315,7 @@ Protected routes accept the session cookie or `Authorization: Bearer $DOCKIFY_AP
| 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 |
| 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`) |
Expand Down Expand Up @@ -421,7 +423,7 @@ Init is idempotent — re-running skips components that already exist (Caddy con
### Export

1. Query all servers (name, host, port, user, SSH key, registry host/user/token)
2. Query all apps (name, domain, port, compose, git_repo, git_branch, auth_user, auth_pass, compose_mode, server name mapping)
2. 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
3. Query all app secrets and config files
4. 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)
5. Generate YAML document
Expand All @@ -432,9 +434,9 @@ Init is idempotent — re-running skips components that already exist (Caddy con
1. Parse YAML file
2. If passphrase provided: attempt decryption of all `enc:` values to validate passphrase (including SSH keys)
3. In **merge** mode (default): skip entries that already exist by same name
4. In **replace** mode: delete all existing servers and apps, then import
4. 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
5. Create servers with SSH key from export (fallback to `"pending"` if key is empty — user must enter key manually)
6. Create apps (status = created, ready for deploy)
6. Create apps (status = created, ready for deploy) with one route per entry in `domains` (exports without `domains` get a route for `domain`)
7. Import secrets and config files
8. Redirect to servers page

Expand Down Expand Up @@ -548,8 +550,10 @@ All styles live in `internal/http/templates/layout.html` as a single `<style>` b
- 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
- 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
- HTTP server: `ReadHeaderTimeout` 10s, `IdleTimeout` 120s; request bodies capped (API JSON 1 MiB, webhooks 1 MiB, import 16 MiB)
- HTTP Basic Auth for apps enforced at Caddy proxy level with bcrypt
- Backup encryption: AES-GCM with PBKDF2 passphrase (600,000 iterations, 256-bit key)
Expand Down
2 changes: 1 addition & 1 deletion cmd/dockify/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func main() {
serverListAdapter := &serverLister{svc: svc}

webhookSecretFn := func() string { s, _ := settingsSvc.GetWebhookSecret(); return s }
router := httppkg.NewRouter(svc, appSvc, httppkg.Render, serverListAdapter, cfg.AdminUser, cfg.AdminPass, cfg.APIToken, cfg.SSHKeyDir, webhookSecretFn, settingsHandler, backupHandler, version, cfg.DevMock)
router := httppkg.NewRouter(svc, appSvc, httppkg.Render, serverListAdapter, cfg.AdminUser, cfg.AdminPass, cfg.APIToken, cfg.SSHKeyDir, webhookSecretFn, settingsHandler, backupHandler, version, cfg.DevMock, cfg.TrustProxy)

if cfg.AdminPass == "" {
log.Println("WARNING: No admin password set (DOCKIFY_ADMIN_PASSWORD). Web UI has no authentication.")
Expand Down
27 changes: 14 additions & 13 deletions internal/app/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strconv"
"strings"

"github.com/coderbuzz/dockify/internal/web"
"github.com/go-chi/chi/v5"
)

Expand Down Expand Up @@ -482,7 +483,7 @@ func (h *Handler) SetFile(w http.ResponseWriter, r *http.Request) {

func (h *Handler) DeleteFile(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
path, err := ValidFilePath(chi.URLParam(r, "path"))
path, err := ValidFilePath(chi.URLParam(r, "*"))
if err != nil {
jsonResponse(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
Expand Down Expand Up @@ -822,7 +823,7 @@ func (h *WebHandler) AppAddForm(w http.ResponseWriter, r *http.Request, render R
go h.service.Deploy(app.ID)
}

http.Redirect(w, r, "/apps/"+strconv.FormatInt(app.ID, 10), http.StatusSeeOther)
web.Redirect(w, r, "/apps/"+strconv.FormatInt(app.ID, 10), http.StatusSeeOther)
}

func (h *WebHandler) AppEditPage(w http.ResponseWriter, r *http.Request, render RenderFunc) {
Expand Down Expand Up @@ -1100,7 +1101,7 @@ func (h *WebHandler) AppEditForm(w http.ResponseWriter, r *http.Request, render
if flashMsg != "" {
redirectURL += "?flash=" + flashMsg
}
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
web.Redirect(w, r, redirectURL, http.StatusSeeOther)
}

func (h *WebHandler) AppDetailPage(w http.ResponseWriter, r *http.Request, render RenderFunc) {
Expand Down Expand Up @@ -1173,7 +1174,7 @@ func (h *WebHandler) refuseBusy(w http.ResponseWriter, r *http.Request, id int64
if !h.service.Busy(id) {
return false
}
http.Redirect(w, r, fmt.Sprintf("/apps/%d?flash=%s", id, url.QueryEscape(busyMsg)), http.StatusSeeOther)
web.Redirect(w, r, fmt.Sprintf("/apps/%d?flash=%s", id, url.QueryEscape(busyMsg)), http.StatusSeeOther)
return true
}

Expand All @@ -1185,15 +1186,15 @@ func (h *WebHandler) AppDeleteWeb(w http.ResponseWriter, r *http.Request, render

go h.service.Undeploy(id)

http.Redirect(w, r, "/apps", http.StatusSeeOther)
web.Redirect(w, r, "/apps", http.StatusSeeOther)
}

func (h *WebHandler) AppClearDeploymentsWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)

_ = h.service.ClearDeployments(id)

http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}

func (h *WebHandler) AppRedeployWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) {
Expand All @@ -1204,7 +1205,7 @@ func (h *WebHandler) AppRedeployWeb(w http.ResponseWriter, r *http.Request, rend

go h.service.Redeploy(id)

http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}

func (h *WebHandler) AppDeployWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) {
Expand All @@ -1217,7 +1218,7 @@ func (h *WebHandler) AppDeployWeb(w http.ResponseWriter, r *http.Request, render
}

if app.ServerID == 0 {
http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
return
}
if h.refuseBusy(w, r, id) {
Expand All @@ -1227,7 +1228,7 @@ func (h *WebHandler) AppDeployWeb(w http.ResponseWriter, r *http.Request, render
h.service.UpdateStatus(id, StatusDeploying)
go h.service.Redeploy(id)

http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}

func (h *WebHandler) AppRollbackWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) {
Expand All @@ -1241,7 +1242,7 @@ func (h *WebHandler) AppRollbackWeb(w http.ResponseWriter, r *http.Request, rend
return
}

http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}

func (h *WebHandler) AppStopWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) {
Expand All @@ -1255,7 +1256,7 @@ func (h *WebHandler) AppStopWeb(w http.ResponseWriter, r *http.Request, render R
return
}

http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}

func (h *WebHandler) AppStartWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) {
Expand All @@ -1269,7 +1270,7 @@ func (h *WebHandler) AppStartWeb(w http.ResponseWriter, r *http.Request, render
return
}

http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}

func (h *WebHandler) AppRedeployAll(w http.ResponseWriter, r *http.Request, render RenderFunc) {
Expand All @@ -1287,7 +1288,7 @@ func (h *WebHandler) AppRedeployAll(w http.ResponseWriter, r *http.Request, rend
}
}

http.Redirect(w, r, "/apps?flash="+url.QueryEscape(fmt.Sprintf("Redeploying %d running apps...", count)), http.StatusSeeOther)
web.Redirect(w, r, "/apps?flash="+url.QueryEscape(fmt.Sprintf("Redeploying %d running apps...", count)), http.StatusSeeOther)
}

func saveFormEnvVars(r *http.Request, svc *Service, appID int64) {
Expand Down
Loading
Loading