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
222 changes: 192 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,26 +95,22 @@ Open `https://<your-domain>` or `http://<controller-ip>:8080`.

Create a `.env` file in the project root or set these environment variables:

```env
# Domain for Caddy reverse proxy (auto HTTPS). Only needed for Mode 1 (Docker Compose).
DOMAIN=dockify.example.com

# Admin credentials (required for web UI login).
# If DOCKIFY_ADMIN_PASSWORD is not set, the web UI has no authentication.
DOCKIFY_ADMIN_USER=admin
DOCKIFY_ADMIN_PASSWORD=your-secure-password

# Cloudflare API credentials (optional, enables automatic DNS A record creation on deploy)
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ZONE_ID=

# Optional: base path when behind a reverse proxy (e.g., code-server: /proxy/9898)
DOCKIFY_BASE_PATH=
```
| 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 + data storage directory |
| `DOCKIFY_SSH_KEY_DIR` | `/var/lib/dockify/keys` | No | Per-server SSH private key storage |
| `DOMAIN` | — | Mode 1 | Domain for Caddy reverse proxy (auto HTTPS) |
| `DOCKIFY_ADMIN_USER` | `admin` | No | Web UI login username |
| `DOCKIFY_ADMIN_PASSWORD` | — | No | Web UI password. If not set, the web UI has **no authentication** |
| `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`) |

### Authentication

The web UI is protected by a login page. To enable authentication, set `DOCKIFY_ADMIN_PASSWORD` in your `.env` or environment:
To enable web UI authentication, set `DOCKIFY_ADMIN_PASSWORD`:

```bash
# In .env
Expand All @@ -131,9 +127,7 @@ DOCKIFY_ADMIN_PASSWORD=secret123 docker compose up -d
- **Session:** Cookie-based, expires after 24 hours
- **Logout:** Visit `/logout`

The login page is at `/login`. After successful login, you are redirected to the dashboard. Webhook endpoints (`/api/webhook/github`, `/api/webhook/gitlab`) and health check (`/health`) do not require authentication.

Dockify runs with sensible defaults. Only `DOMAIN` is required for Option A. `CLOUDFLARE_*` is optional and only needed if you want automated DNS records. `DOCKIFY_BASE_PATH` is only needed when accessing Dockify through a URL prefix (e.g., code-server proxy).
The login page is at `/login`. Webhook endpoints (`/api/webhook/github`, `/api/webhook/gitlab`) and health check (`/health`) do not require authentication.

### Step 2: Prepare a Worker VM

Expand Down Expand Up @@ -208,29 +202,54 @@ ssh-copy-id -i ~/.ssh/dockify.pub root@<worker-ip>

Dockify can auto-deploy on every push via GitHub or GitLab webhooks. When an app is created with a `Git Repo URL` and `Branch`, Dockify matches incoming webhooks by repo + branch and triggers a redeploy.

Each app with a Git repo gets an auto-generated **webhook secret** (64-character hex string). You can view and roll (re-generate) it in the app detail page.
A global **webhook secret** is auto-generated on first startup. See the [Webhook Security](#webhook-security) section below for details on how it works and how to configure it.

### Setup

1. In your **app repo** (the one you want to auto-deploy), go to **Settings → Webhooks → Add webhook**
2. Fill in:
```
Payload URL: https://dockify.amg.id/api/webhook/github
Payload URL: https://dockify.example.com/api/webhook/github
Content type: application/json
Secret: <copy from Dockify app detail — Webhook Secret field>
Secret: <copy from Dockify Settings page — Webhook Secret field>
Events: Just the push event
```
3. In the Dockify UI, when creating the app, fill in:
- **Git Repo URL:** `https://github.com/user/repo.git`
- **Branch:** `main`

Dockify verifies incoming webhooks using HMAC-SHA256 (GitHub) or secret token (GitLab). If the secret doesn't match, the webhook is rejected with 401. Apps created before the secret feature still work without verification (no secret set).
Dockify verifies incoming webhooks using HMAC-SHA256 (GitHub) or secret token (GitLab). If the secret doesn't match, the webhook is rejected with 401. Non-push events are ignored gracefully (returns 200 with `"ignored"`).

### Webhook Security

Dockify auto-generates a global **webhook secret** (64-character hex) on first startup, stored in the database. You can view, copy, regenerate (roll), or disable it on the **Settings** page (`/settings`). When disabled, webhooks are accepted without signature verification.

When the webhook secret is set, Dockify validates incoming webhooks:

- **GitHub** — HMAC-SHA256 via `X-Hub-Signature-256` header
- **GitLab** — plain token comparison via `X-Gitlab-Token` header

If the signature or token does not match, the webhook is rejected with `401 Unauthorized`.

**To configure:**

Dockify ignores non-push events gracefully (returns 200 with `"ignored"`).
1. Go to **Settings** in the Dockify web UI
2. Click **Show** → **Copy** the webhook secret
3. Paste it as the **Secret** in your GitHub/GitLab webhook settings
4. For GitHub Actions, pass the secret via `${{ secrets.DOCKIFY_WEBHOOK_SECRET }}` (see examples below)

### Sample GitHub Actions Workflow
> **Note:** When you **Roll** the secret, the old one stops working immediately. Update all CI secrets to match. Use **Disable** to turn off secret verification entirely.

You can also trigger deploy from GitHub Actions directly, without setting up a webhook. This gives you more control (run tests first, then deploy):
### Trigger Deploy via GitHub Actions

You can trigger deploys from GitHub Actions with or without webhook secret verification.

Add repository secrets (**Settings → Secrets and variables → Actions**):

- `DOCKIFY_URL` — your Dockify URL (e.g. `https://dockify.example.com`)
- `DOCKIFY_WEBHOOK_SECRET` — the webhook secret from Dockify Settings (for Option B)

**Option A: Without webhook secret** (simple; disable the webhook secret on the Settings page first, or use when no secret is configured)

```yaml
name: Deploy via Dockify
Expand All @@ -252,8 +271,10 @@ jobs:
# npm test, go test, etc.

- name: Trigger Dockify deploy
env:
DOCKIFY_URL: ${{ secrets.DOCKIFY_URL }}
run: |
curl -s -X POST https://dockify.example.com/api/webhook/github \
curl -s -X POST $DOCKIFY_URL/api/webhook/github \
-H "Content-Type: application/json" \
-d '{
"ref": "refs/heads/main",
Expand All @@ -264,6 +285,51 @@ jobs:
}'
```

**Option B: With webhook secret** (HMAC-SHA256, recommended for public-facing deployments)

```yaml
name: Deploy via Dockify

on:
push:
branches: [main]

jobs:
test-and-deploy:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Run tests
run: |
echo "Running tests..."
# npm test, go test, etc.

- name: Trigger Dockify deploy
env:
DOCKIFY_URL: ${{ secrets.DOCKIFY_URL }}
DOCKIFY_WEBHOOK_SECRET: ${{ secrets.DOCKIFY_WEBHOOK_SECRET }}
run: |
REPO="https://github.com/${{ github.repository }}.git"
BRANCH="${GITHUB_REF#refs/heads/}"
COMMIT="${{ github.sha }}"

PAYLOAD=$(cat <<EOF
{"ref":"refs/heads/${BRANCH}","after":"${COMMIT}","repository":{"clone_url":"${REPO}"}}
EOF
)

SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$DOCKIFY_WEBHOOK_SECRET" | sed 's/^.* //')

curl -s -o /dev/null -w "%{http_code}" \
-X POST $DOCKIFY_URL/api/webhook/github \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: push" \
-H "X-Hub-Signature-256: sha256=${SIGNATURE}" \
-d "$PAYLOAD"
```

Dockify matches the `clone_url` and `ref` against registered apps, then redeploys the matching app with the commit SHA recorded in deployment history.

### How It Works
Expand All @@ -274,6 +340,100 @@ Dockify matches the `clone_url` and `ref` against registered apps, then redeploy
4. Triggers `deployWithCommit(app.ID, commitSHA)` — same deploy flow as UI
5. Records the deployment with commit SHA in history

## App Configuration

### Environment Variables (Secrets)

Each app can have key-value secrets stored in Dockify. On deploy, they are written as a `.env` file to `/opt/dockify/apps/<name>/.env` on the worker VM.

Manage via the app detail page in the Web UI, or the API:

```
GET /api/apps/:id/secrets List secrets
POST /api/apps/:id/secrets Set a secret (body: `{"key":"...","value":"..."}`)
DELETE /api/apps/:id/secrets/:key Delete a secret
```

### Config Files

Each app can have arbitrary config files. On deploy, they are written to `/opt/dockify/apps/<name>/<path>` on the worker VM.

```
GET /api/apps/:id/files List files
POST /api/apps/:id/files Set a file (body: `{"path":"...","content":"..."}`)
DELETE /api/apps/:id/files/:path Delete a file
```

### Stop / Start

Pause and resume an app without undeploying or losing configuration:

```
POST /api/apps/:id/stop Runs `docker compose stop`
POST /api/apps/:id/start Runs `docker compose start`
```

### Unique Service Name

When enabled, Dockify renames the first service in the docker-compose file to a sanitized version of the app name (replacing `.`, `_`, spaces with `-`). This prevents service name conflicts when multiple apps use the same compose template.

### HTTP Basic Auth

Set `auth_user` and `auth_pass` on an app to require HTTP basic auth before accessing it. Caddy enforces this at the proxy level — the app itself does not need to handle authentication.

## Server Features

### Resource Monitoring

Dockify collects CPU, RAM, and disk usage from all online servers every 60 seconds. Resource data is visible on server detail pages and used by the scheduler to pick the least-loaded server for new deployments.

```
POST /api/servers/:id/refresh Manually refresh a server's resource metrics
```

### Server Edit

Update a server's host, port, user, or SSH key after creation:

```
PATCH /api/servers/:id Partial update of server fields
```

## Backup & Restore

Export your configuration (servers + apps) as YAML and import it into another instance.

### Export

```
GET /api/backup/export Download YAML config file
```

The export includes all servers (name, host, port, user) and apps (name, domain, port, compose, git repo, auth settings). **SSH keys are not exported** — you must re-enter them after import.

### Import

Upload a previously exported YAML file via the Import page (`/import`) or API:

```
POST /api/backup/import Upload YAML file (multipart: `file` + `mode`)
```

- **merge** (default): skip entries that already exist by name
- **replace**: delete all existing servers and apps before import

## Settings & Updates

The **Settings** page (`/settings`) provides:

| Feature | Description |
|---|---|
| **Webhook Secret** | View, copy, or roll (regenerate) the global webhook secret |
| **Update Check** | Checks GitHub Releases for a newer version |
| **Run Update** | Triggers a self-update via `systemd-run` — downloads and runs the latest `update.sh` |

Manual update via command line:

## Updating Dockify

A universal update script detects your install mode and updates accordingly:
Expand Down Expand Up @@ -305,12 +465,14 @@ dockify/
├── cmd/dockify/main.go
├── internal/
│ ├── ssh/ # SSH client, remote exec, worker init
│ ├── vm/ # VM CRUD, resource monitoring
│ ├── server/ # Server CRUD, resource monitoring
│ ├── app/ # App CRUD, deployment
│ ├── caddy/ # Caddy Admin API client
│ ├── cloudflare/ # Cloudflare DNS API
│ ├── webhook/ # Git webhook handler
│ ├── scheduler/ # Idle VM selection
│ ├── scheduler/ # Idle server selection
│ ├── settings/ # Global settings, update checker
│ ├── backup/ # Export/import YAML config
│ ├── db/ # SQLite layer
│ └── http/ # HTTP server, handlers, templates
├── web/static/ # CSS, JS (embedded)
Expand Down
16 changes: 16 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# TODO

## Webhook Secret

- [ ] **Settings page**: Add toggle to disable/clear the webhook secret.
- Currently the secret is auto-generated at startup (`internal/settings/settings.go:31` `ensureWebhookSecret()`) and cannot be disabled — only show/hide, copy, roll.
- Affected: `internal/http/templates/settings.html`, `internal/settings/settings.go`, `internal/settings/handler.go`
- After adding disable, update README Option A note to mention this feature.

- [ ] **Settings template**: Replace hardcoded `dockify.amg.id` with a generic example domain in the GitHub Actions workflow snippet.
- File: `internal/http/templates/settings.html:33`

## Backup

- [ ] **Fix misleading YAML header comment**: `internal/backup/backup.go:95` says "Secrets (SSH keys, auth passwords if present) are included as saved" but SSH keys are **not** included in the export (`ExportServer` struct has no SSH key field). Either fix the comment or add SSH key export support.
- File: `internal/backup/backup.go:94-96`
4 changes: 2 additions & 2 deletions cmd/dockify/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,15 @@ func main() {
appSvc := app.NewService(appRepo, serverRepo, cfClient, sch)

settingsSvc := settings.NewService(database, version)
webhookSecret, _ := settingsSvc.GetWebhookSecret()
settingsHandler := settings.NewHandler(settingsSvc)

backupSvc := backup.NewService(svc, appSvc, cfg.SSHKeyDir)
backupHandler := backup.NewHandler(backupSvc)

serverListAdapter := &serverLister{svc: svc}

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

if cfg.AdminPass == "" {
log.Println("WARNING: No admin password set (DOCKIFY_ADMIN_PASSWORD). Web UI has no authentication.")
Expand Down
2 changes: 1 addition & 1 deletion internal/backup/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func (s *Service) Export() (string, error) {

sb := strings.Builder{}
sb.WriteString("# Dockify Configuration Export\n")
sb.WriteString("# Secrets (SSH keys, auth passwords if present) are included as saved.\n")
sb.WriteString("# Auth passwords (if present) are included as saved. SSH keys are not exported.\n")
sb.WriteString("# Remove or edit entries before import as needed.\n")
sb.Write(out)
return sb.String(), nil
Expand Down
6 changes: 4 additions & 2 deletions internal/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
chimw "github.com/go-chi/chi/v5/middleware"
)

func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serverListAdapter app.ServerRepo, cfgUser, cfgPass, sshKeyDir, webhookSecret string, settingsHandler *settings.Handler, backupHandler *backup.Handler, version string) *chi.Mux {
func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serverListAdapter app.ServerRepo, cfgUser, cfgPass, sshKeyDir string, webhookSecretFn func() string, settingsHandler *settings.Handler, backupHandler *backup.Handler, version string) *chi.Mux {
r := chi.NewRouter()

r.Use(chimw.Logger)
Expand All @@ -31,7 +31,7 @@ func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serv
appAPIHandler := app.NewHandler(appSvc)
appWebHandler := app.NewWebHandler(appSvc, serverListAdapter)

whHandler := webhook.NewHandler(appSvc, webhookSecret)
whHandler := webhook.NewHandler(appSvc, webhookSecretFn)

// Public routes (no auth required)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -174,6 +174,8 @@ func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serv
})
r.Get("/api/settings/webhook-secret", settingsHandler.GetWebhookSecret)
r.Post("/api/settings/webhook-secret/roll", settingsHandler.RollWebhookSecret)
r.Post("/api/settings/webhook-secret/disable", settingsHandler.DisableWebhookSecret)
r.Post("/api/settings/webhook-secret/enable", settingsHandler.EnableWebhookSecret)
r.Post("/api/settings/update/run", settingsHandler.RunUpdate)

r.Get("/about", func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading
Loading