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
2 changes: 1 addition & 1 deletion .air.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ cmd = "go build -o ./tmp/dockify ./cmd/dockify"
exclude_dir = ["assets", "tmp", "vendor", "data", "scripts"]
exclude_regex = ["_test\\.go"]
exclude_unchanged = true
full_bin = "DOCKIFY_DATA_DIR=./data ./tmp/dockify serve"
full_bin = "DOCKIFY_HOST=${DOCKIFY_HOST:-127.0.0.1} DOCKIFY_DATA_DIR=./data ./tmp/dockify serve"
include_dir = ["cmd", "internal"]
include_ext = ["go", "html"]
kill_delay = "500ms"
Expand Down
24 changes: 21 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ name: Build and Publish

on:
push:
branches: [main]
tags: ['v*']
pull_request:

env:
REGISTRY: ghcr.io
Expand All @@ -22,12 +24,23 @@ jobs:
uses: actions/setup-go@v5
with:
go-version: '1.26'
check-latest: true

- name: Vet
run: go vet ./...

- name: Format
run: |
out=$(gofmt -l .)
if [ -n "$out" ]; then echo "gofmt needed:"; echo "$out"; exit 1; fi

- name: Test
run: go test ./...
run: go test -race ./...

- name: Vulnerability check
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
"$(go env GOPATH)/bin/govulncheck" ./...

- name: Smoke test
run: bash scripts/smoke-test.sh
Expand All @@ -40,13 +53,16 @@ jobs:
-ldflags="-s -w -X main.version=${VERSION}" \
-o dockify-linux-amd64 \
./cmd/dockify
sha256sum dockify-linux-amd64 > dockify-linux-amd64.sha256

- name: Upload binary
if: startsWith(github.ref, 'refs/tags/v')
uses: actions/upload-artifact@v4
with:
name: dockify-linux-amd64
path: dockify-linux-amd64
path: |
dockify-linux-amd64
dockify-linux-amd64.sha256
retention-days: 7

- name: Set up Docker Buildx
Expand Down Expand Up @@ -113,5 +129,7 @@ jobs:
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: dockify-linux-amd64
files: |
dockify-linux-amd64
dockify-linux-amd64.sha256
generate_release_notes: true
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ go vet ./... # lint
go mod tidy # clean dependencies

# Run once (no auto-reload)
DOCKIFY_DATA_DIR=./data go run ./cmd/dockify serve
DOCKIFY_HOST=127.0.0.1 DOCKIFY_DATA_DIR=./data go run ./cmd/dockify serve

# Verify templates parse
go test ./internal/http/... -run TestTemplates

# Build and run with Docker
docker build -t dockify .
docker run -p 8080:8080 -v $(pwd)/data:/var/lib/dockify dockify
docker run -p 127.0.0.1:8080:8080 -e DOCKIFY_ALLOW_NO_AUTH=true -v $(pwd)/data:/var/lib/dockify dockify
```

### Dev Mode
Expand Down
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,17 @@ curl -fsSL https://raw.githubusercontent.com/coderbuzz/dockify/main/scripts/inst
cd /opt/dockify && docker compose up -d
```

The image binds `DOCKIFY_HOST=0.0.0.0`, so the container needs `DOCKIFY_ADMIN_PASSWORD` in `/opt/dockify/.env` (the installer asks for it) or it exits at start; `DOCKIFY_ALLOW_NO_AUTH=true` overrides this.

Worker SSH keys live in the `dockify_data` volume (`/var/lib/dockify/keys`); the host's `~/.ssh` is not mounted. Dockify generates each worker's key when you add the server and shows the public key on the server page (Step 2). Older compose files mounted `${HOME}/.ssh` read-only, which made adding a server fail with `read-only file system`: re-download `docker-compose.yml` and run `docker compose up -d`.

**Option D: Build from source (development)**

```bash
git clone https://github.com/coderbuzz/dockify.git
cd dockify
go build -o dockify ./cmd/dockify
./dockify serve
DOCKIFY_HOST=127.0.0.1 ./dockify serve # or set DOCKIFY_ADMIN_PASSWORD
```

**All installation methods share the same architecture for worker VMs:**
Expand All @@ -106,7 +110,8 @@ Create a `.env` file in the project root or set these environment variables:
| `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** |
| `DOCKIFY_ADMIN_PASSWORD` | — | Yes, unless bound to loopback | Web UI password. Without it Dockify refuses to start on a non-loopback `DOCKIFY_HOST` |
| `DOCKIFY_ALLOW_NO_AUTH` | `false` | No | `true` lets Dockify start without a password on a non-loopback bind (web UI open to anyone who can reach it) |
| `DOCKIFY_API_TOKEN` | — | No | Bearer token for scripts/agents calling `/api/*` (needs `DOCKIFY_ADMIN_PASSWORD` set) |
| `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 |
Expand All @@ -127,7 +132,7 @@ DOCKIFY_ADMIN_PASSWORD=secret123 docker compose up -d
```

- **Default username:** `admin` (configurable via `DOCKIFY_ADMIN_USER`)
- **No password set:** Web UI has **no authentication** (open to anyone)
- **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
- **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)
Expand Down Expand Up @@ -178,7 +183,7 @@ The `public_key` is also returned by `POST /api/servers` (send no `ssh_key`) and
2. Install Docker via `get.docker.com` (if not present)
3. Install Docker Compose plugin (if not present)
4. Create `dockify` Docker network
5. Deploy Caddy container (port 80/443 + Admin API on localhost:2019)
5. Deploy Caddy container `caddy:2` (port 80/443 + Admin API on localhost:2019). A `caddy` container Dockify did not create (no `/opt/dockify/caddy` mount, no `dockify.managed=true` label) is left alone and init fails until it is removed or renamed
6. Collect CPU, RAM, Disk info
7. Status → **online**. Ready to deploy apps.

Expand Down Expand Up @@ -497,7 +502,7 @@ The **Settings** page (`/settings`) provides centralized management:
|---|---|
| **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` |
| **Run Update** | Triggers a self-update via `systemd-run` — runs `update.sh` from the latest release tag found by the update check (refused when none is known); output in `journalctl -u dockify-upgrade` |

The **About** page (`/about`) shows the current version, project description, and a **Sponsor** link. An update progress bar with live polling keeps you informed during self-updates.

Expand Down Expand Up @@ -535,6 +540,8 @@ A universal update script detects your install mode and updates accordingly:
curl -fsSL https://raw.githubusercontent.com/coderbuzz/dockify/main/scripts/update.sh | bash
```

Binary installs are checked against the release's `dockify-linux-amd64.sha256` before the binary is replaced; the update aborts if the file is missing (releases published before checksums have none) or does not match. `DOCKIFY_VERSION=vX.Y.Z` pins the release.

Or manually per mode:

**Mode 1 (Docker Compose):**
Expand Down
16 changes: 9 additions & 7 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ scripts/
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: vet, test, smoke test, build binary + Docker, release on v* tag
.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
Expand All @@ -102,7 +102,8 @@ docker-compose.yml # Dockify + Caddy reverse proxy (mode 1)
| `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` | — | No | Web UI password. Not set = no authentication |
| `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`) |
Expand Down Expand Up @@ -353,7 +354,7 @@ Protected routes accept the session cookie or `Authorization: Bearer $DOCKIFY_AP
| 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 via systemd-run |
| 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` |
| POST | `/api/backup/export` | Download YAML export |
| POST | `/api/backup/import` | Upload YAML import (multipart: `file` + `mode`), ≤ 16 MiB |

Expand Down Expand Up @@ -397,10 +398,11 @@ When a server is initialized (`POST /api/servers/:id/init`):
2. Install Docker via `get.docker.com` (if not present)
3. Install Docker Compose plugin (if not present)
4. Create `dockify` Docker network
5. Deploy Caddy container (ports 80/443 + Admin API on localhost:2019)
6. Collect CPU cores (`nproc`), RAM total (`/proc/meminfo`), disk total (`df -BG`)
7. Collect CPU usage (`/proc/stat`), RAM usage (`/proc/meminfo`), disk usage (`df -BG`)
8. Status → online
5. 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
6. 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
7. Collect CPU cores (`nproc`), RAM total (`/proc/meminfo`), disk total (`df -BG`)
8. Collect CPU usage (`/proc/stat`), RAM usage (`/proc/meminfo`), disk usage (`df -BG`)
9. Status → online

Init is idempotent — re-running skips components that already exist (Caddy container, Docker, network).

Expand Down
3 changes: 3 additions & 0 deletions cmd/dockify/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ func main() {
}

cfg := config.Load()
if err := cfg.CheckAuth(); err != nil {
log.Fatal(err)
}

database, err := db.Open(cfg.DBPath())
if err != nil {
Expand Down
2 changes: 0 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@ services:
env_file: .env
volumes:
- dockify_data:/var/lib/dockify
- ${HOME}/.ssh:/home/dockify/.ssh:ro
environment:
DOCKIFY_HOST: 0.0.0.0
DOCKIFY_PORT: 8080
DOCKIFY_DATA_DIR: /var/lib/dockify
DOCKIFY_SSH_KEY_DIR: /home/dockify/.ssh
networks:
- dockify

Expand Down
68 changes: 34 additions & 34 deletions internal/app/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,27 @@ import (
)

type App struct {
ID int64 `json:"id"`
Name string `json:"name"`
ServerID int64 `json:"server_id"`
Domain string `json:"domain"`
Port int `json:"port"`
Compose string `json:"compose"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
AuthUser string `json:"auth_user"`
AuthPass string `json:"auth_pass"`
Status string `json:"status"`
ComposeMode string `json:"compose_mode"`
MemoryLimit string `json:"memory_limit"`
CPULimit string `json:"cpu_limit"`
LogMaxSize string `json:"log_max_size"`
LogMaxFile string `json:"log_max_file"`
Command string `json:"command"`
Ports string `json:"ports"`
UlimitsNofile string `json:"ulimits_nofile"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
Name string `json:"name"`
ServerID int64 `json:"server_id"`
Domain string `json:"domain"`
Port int `json:"port"`
Compose string `json:"compose"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
AuthUser string `json:"auth_user"`
AuthPass string `json:"auth_pass"`
Status string `json:"status"`
ComposeMode string `json:"compose_mode"`
MemoryLimit string `json:"memory_limit"`
CPULimit string `json:"cpu_limit"`
LogMaxSize string `json:"log_max_size"`
LogMaxFile string `json:"log_max_file"`
Command string `json:"command"`
Ports string `json:"ports"`
UlimitsNofile string `json:"ulimits_nofile"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

type Deployment struct {
Expand All @@ -42,19 +42,19 @@ type Deployment struct {
}

type ContainerStats struct {
ID int64 `json:"id"`
AppID int64 `json:"app_id"`
ServerID int64 `json:"server_id"`
ContainerName string `json:"container_name"`
CPUPercent float64 `json:"cpu_percent"`
MemUsageBytes int64 `json:"mem_usage_bytes"`
MemLimitBytes int64 `json:"mem_limit_bytes"`
MemPercent float64 `json:"mem_percent"`
NetIORxBytes int64 `json:"net_io_rx_bytes"`
NetIOTxBytes int64 `json:"net_io_tx_bytes"`
BlockIORead int64 `json:"block_io_read"`
BlockIOWrite int64 `json:"block_io_write"`
CreatedAt time.Time `json:"created_at"`
ID int64 `json:"id"`
AppID int64 `json:"app_id"`
ServerID int64 `json:"server_id"`
ContainerName string `json:"container_name"`
CPUPercent float64 `json:"cpu_percent"`
MemUsageBytes int64 `json:"mem_usage_bytes"`
MemLimitBytes int64 `json:"mem_limit_bytes"`
MemPercent float64 `json:"mem_percent"`
NetIORxBytes int64 `json:"net_io_rx_bytes"`
NetIOTxBytes int64 `json:"net_io_tx_bytes"`
BlockIORead int64 `json:"block_io_read"`
BlockIOWrite int64 `json:"block_io_write"`
CreatedAt time.Time `json:"created_at"`
}

type RouteStats struct {
Expand Down
6 changes: 3 additions & 3 deletions internal/cloudflare/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ type DNSRecord struct {
}

type apiResponse struct {
Success bool `json:"success"`
Errors []apiError `json:"errors"`
Messages []string `json:"messages"`
Success bool `json:"success"`
Errors []apiError `json:"errors"`
Messages []string `json:"messages"`
Result json.RawMessage `json:"result"`
}

Expand Down
21 changes: 19 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package config

import (
"errors"
"net"
"os"
"path/filepath"
"strings"
)

type Config struct {
Expand All @@ -18,7 +21,8 @@ type Config struct {
CloudflareAPIToken string
CloudflareZoneID string

DevMock bool
AllowNoAuth bool
DevMock bool
}

func Load() *Config {
Expand All @@ -34,7 +38,8 @@ func Load() *Config {
CloudflareAPIToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),

DevMock: os.Getenv("DOCKIFY_DEV_MOCK") == "true",
AllowNoAuth: os.Getenv("DOCKIFY_ALLOW_NO_AUTH") == "true",
DevMock: os.Getenv("DOCKIFY_DEV_MOCK") == "true",
}

os.MkdirAll(cfg.DataDir, 0700)
Expand All @@ -55,6 +60,18 @@ func (c *Config) AuthEnabled() bool {
return c.AdminPass != ""
}

// CheckAuth refuses a passwordless UI on a non-loopback bind unless the
// operator opted in with DOCKIFY_ALLOW_NO_AUTH=true.
func (c *Config) CheckAuth() error {
h := strings.Trim(c.Host, "[]")
if c.AdminPass != "" || c.AllowNoAuth || h == "localhost" || net.ParseIP(h).IsLoopback() {
return nil
}
return errors.New("refusing to start: no DOCKIFY_ADMIN_PASSWORD set and DOCKIFY_HOST=" + c.Host +
" is reachable from the network. Set DOCKIFY_ADMIN_PASSWORD, bind to DOCKIFY_HOST=127.0.0.1," +
" or set DOCKIFY_ALLOW_NO_AUTH=true to run without authentication")
}

func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
Expand Down
20 changes: 20 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,23 @@ func TestDBPath(t *testing.T) {
t.Errorf("DBPath: expected /var/lib/dockify/dockify.db, got %s", cfg.DBPath())
}
}

func TestCheckAuth(t *testing.T) {
cases := []struct {
cfg Config
ok bool
}{
{Config{Host: "0.0.0.0"}, false},
{Config{Host: "10.0.0.5"}, false},
{Config{Host: "0.0.0.0", AdminPass: "x"}, true},
{Config{Host: "0.0.0.0", AllowNoAuth: true}, true},
{Config{Host: "127.0.0.1"}, true},
{Config{Host: "::1"}, true},
{Config{Host: "localhost"}, true},
}
for _, c := range cases {
if err := c.cfg.CheckAuth(); (err == nil) != c.ok {
t.Errorf("%+v: got err=%v, want ok=%v", c.cfg, err, c.ok)
}
}
}
Loading
Loading