From abed2d2c6994e12aaf3b5ed4a9b536805ce23693 Mon Sep 17 00:00:00 2001 From: Pacerino Date: Sun, 14 Jun 2026 02:35:56 +0200 Subject: [PATCH] [CI] Add Dockerfile, entrypoint and GitHub Actions pipeline Multi-stage Dockerfile builds the frontend and backend into a single Alpine image bundling Caddy, started via an entrypoint. CI runs backend tests (with race detector), builds the frontend and publishes the image to GHCR on master/tags. Targets a self-hosted runner with a local build cache. Updates .gitignore (secrets/runtime state) and README. --- .dockerignore | 10 ++++ .github/workflows/ci.yml | 118 +++++++++++++++++++++++++++++++++++++++ .gitignore | 26 +++++++-- Dockerfile | 48 ++++++++++++++++ README.md | 84 +++++++++++++++++++++++++--- docker/entrypoint.sh | 30 ++++++++++ 6 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100755 docker/entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..33dc647 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +**/node_modules +frontend/dist +frontend/.vite +backend/embed/assets +**/*.db +.git +.github +*.md +.weave +.vscode diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..280be03 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,118 @@ +name: CI + +on: + push: + branches: [master] + tags: ["v*"] + pull_request: + branches: [master] + +permissions: + contents: read + packages: write + +# Cancel superseded runs for the same ref. Useful on a single self-hosted +# runner so a new push doesn't queue behind an outdated run. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE_NAME: ghcr.io/${{ github.repository }} + +jobs: + test-backend: + name: Test backend + runs-on: self-hosted + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache-dependency-path: backend/go.sum + # The backend embeds frontend assets; provide a placeholder so it builds. + - name: Create embed placeholder + run: mkdir -p embed/assets && echo "" > embed/assets/index.html + - name: Vet + run: go vet ./... + - name: Test + run: go test -race -count=1 ./... + + test-frontend: + name: Build frontend + runs-on: self-hosted + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install + run: npm ci || npm install + - name: Type-check and build + run: | + mkdir -p ../backend/embed/assets + npm run build + + docker: + name: Build & publish image + runs-on: self-hosted + needs: [test-backend, test-frontend] + # Only push on master pushes and version tags, not on PRs. + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ github.ref_name }} + COMMIT=${{ github.sha }} + # Local cache persists on the self-hosted runner's disk between runs. + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max + + # Replace the old cache with the new one to keep it from growing + # unbounded across runs. + - name: Rotate build cache + if: always() + run: | + rm -rf /tmp/.buildx-cache + if [ -d /tmp/.buildx-cache-new ]; then + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + fi diff --git a/.gitignore b/.gitignore index 0e74ab7..90d4663 100644 --- a/.gitignore +++ b/.gitignore @@ -5,21 +5,39 @@ frontend/node_modules frontend/.pnp frontend/.pnp.js +# frontend build / cache +frontend/dist +frontend/.vite +frontend/*.tsbuildinfo + # testing frontend/coverage -# production -frontend/build - # misc .DS_Store +.env .env.local .env.development.local .env.test.local .env.production.local +# secrets / runtime data (never commit) +*.pem +*.key +*.db +*.sqlite +*.sqlite3 +jwtkey.pem + +# agent / tooling session state +.weave +.weave/ + frontend/npm-debug.log* frontend/yarn-debug.log* frontend/yarn-error.log* + +# embedded frontend build output (generated by `npm run build`) backend/embed/assets -.vscode \ No newline at end of file + +.vscode diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d9284c4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1 + +# ---- Frontend build ---- +FROM node:22-alpine AS frontend +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm ci || npm install +COPY frontend/ ./ +# Vite is configured (vite.config.ts) to emit into ../backend/embed/assets, +# so create that target and build into it. +RUN mkdir -p /app/backend/embed/assets && npm run build + +# ---- Backend build ---- +FROM golang:1.25-alpine AS backend +# CGO is required by the sqlite driver (mattn/go-sqlite3). +RUN apk add --no-cache build-base +WORKDIR /app/backend +COPY backend/go.mod backend/go.sum ./ +RUN go mod download +COPY backend/ ./ +# Bring in the embedded frontend assets produced in the previous stage so the +# `go:embed all:assets/**` directive has files to embed. +COPY --from=frontend /app/backend/embed/assets ./embed/assets +ARG VERSION=dev +ARG COMMIT=unknown +RUN CGO_ENABLED=1 GOOS=linux go build \ + -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \ + -o /out/cpm ./cmd/main.go + +# ---- Runtime ---- +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates caddy +COPY --from=backend /out/cpm /usr/local/bin/cpm +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +# Sensible container defaults: keep data in /data, talk to Caddy over the API. +ENV CPM_DATAFOLDER=/data \ + CPM_LOGFOLDER=/data/logs \ + CPM_CADDYFILE=/data/Caddyfile \ + CPM_CADDY_MODE=api \ + CPM_CADDY_ADMINURL=http://localhost:2019 + +VOLUME ["/data"] +EXPOSE 3001 80 443 +# The entrypoint launches Caddy (admin API) then CPM. Override the CMD or set +# CADDY_CONFIG to bring your own Caddy configuration. +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/README.md b/README.md index a4737f9..f02d4bd 100644 --- a/README.md +++ b/README.md @@ -15,24 +15,94 @@ Caddy is installed normally on the system and integrates further Caddyfiles via ## Current features -- Adding hosts with multiple domains/upstreams -- Delete hosts +- Adding, editing and deleting hosts with multiple domains/upstreams +- Two ways to apply config to Caddy, selectable at runtime: + - **Caddyfile mode** (`CPM_CADDY_MODE=caddyfile`, default): renders a per-host + Caddyfile snippet and triggers a reload. + - **API mode** (`CPM_CADDY_MODE=api`): manages Caddy's JSON config through the + admin API, maintaining one route per host (no reload needed). +- Configurable authentication: local email/password or external OIDC/OAuth + (`CPM_AUTH_MODE=local|oidc`) with JIT user provisioning. +- Modern SPA frontend (Vite + React + TypeScript + Tailwind + shadcn/ui), + embedded in the binary by default or served from an external directory. + +## Configuration + +All options are environment variables prefixed with `CPM_`: + +| Variable | Default | Description | +| --- | --- | --- | +| `CPM_CADDY_MODE` | `caddyfile` | `caddyfile` or `api` | +| `CPM_CADDY_ADMINURL` | `http://localhost:2019` | Caddy admin API base URL (api mode + api reload) | +| `CPM_CADDY_SERVERNAME` | `srv0` | http server name routes are managed under (api mode) | +| `CPM_CADDY_LISTEN` | `:80,:443` | listen addresses for the bootstrapped server (api mode); use e.g. `:8080` for local non-root testing | +| `CPM_CADDY_RELOADSTRATEGY` | `systemd` | `systemd`, `exec`, `api` or `none` (caddyfile mode) | +| `CPM_CADDY_BINARY` | `caddy` | caddy executable for the `exec` reload strategy | +| `CPM_CADDY_SERVICE` | `caddy.service` | systemd unit for the `systemd` reload strategy | +| `CPM_DATAFOLDER` | `/etc/caddy/` | data + per-host config folder | +| `CPM_LOGFOLDER` | `/var/log/caddy` | per-host log folder | +| `CPM_CADDYFILE` | `/etc/caddy/Caddyfile` | main Caddyfile (used by `exec`/`api` reload) | +| `CPM_FRONTENDDIR` | _(empty)_ | serve the frontend from this directory instead of the embedded assets | +| `CPM_AUTH_MODE` | `local` | `local` or `oidc` | +| `CPM_AUTH_OIDC_ISSUER` | _(empty)_ | OIDC issuer URL (e.g. Keycloak/Authelia) | +| `CPM_AUTH_OIDC_CLIENTID` | _(empty)_ | OIDC client id | +| `CPM_AUTH_OIDC_CLIENTSECRET` | _(empty)_ | OIDC client secret | +| `CPM_AUTH_OIDC_REDIRECTURL` | _(empty)_ | callback URL, e.g. `https://cpm.example.com/api/auth/oidc/callback` | +| `CPM_AUTH_OIDC_SCOPES` | `openid,profile,email` | requested scopes | +| `CPM_AUTH_OIDC_ALLOWEDDOMAINS` | _(empty)_ | optional email-domain allowlist for JIT provisioning | ## Planned features -- Login with third-party Services like Authelia, Keycloak etc. -- Editing hosts - Logview - Manage Plugins ## FAQ -> Why don't you use the API from Caddy itself? +> Can I use the Caddy admin API instead of writing a Caddyfile? -The API of Caddy is not documented and quite complicated for a simple web interface. Many features like HSTS, HTTP/2, SSL etc. are already included in Caddy and don't need to be specially configured. +Yes. Set `CPM_CADDY_MODE=api` and CPM will manage Caddy's JSON config through the +admin API, keeping one route per host. The Caddyfile mode remains the default and +is the simplest setup, since features like HSTS, HTTP/2, SSL etc. come for free. + +> Can I use external SSO (Authelia, Keycloak, etc.)? + +Yes. Set `CPM_AUTH_MODE=oidc` and configure the `CPM_AUTH_OIDC_*` variables. On +first successful login CPM creates the matching user automatically (JIT). Use +`CPM_AUTH_OIDC_ALLOWEDDOMAINS` to restrict which email domains may sign in. > How can I use CPM? -You have to compile CPM yourself. The frontend is based on ReactJS with Typescript, the compiled frontend must then be added under ``backend/assets``. The backend is written in GoLang and can be easily compiled using ``go build cmd/main.go``. +You have to compile CPM yourself. The frontend (Vite + React + TypeScript) is built from the ``frontend`` directory with ``npm install && npm run build``, which outputs straight into ``backend/embed/assets``. The backend is written in Go (1.24+) and can be compiled with ``go build ./cmd/main.go`` from the ``backend`` directory, producing a single binary with the frontend embedded. During development run ``npm run dev`` (Vite proxies ``/api`` to the Go backend on port 3001). + +## Docker + +A multi-stage `Dockerfile` builds the frontend, compiles the backend (CGO is +required by the SQLite driver) and produces a small Alpine runtime image that +bundles Caddy. The container entrypoint starts Caddy with its admin API and then +CPM in `api` mode. + +```bash +docker build -t caddyproxymanager . +docker run -d --name cpm \ + -p 3001:3001 -p 80:80 -p 443:443 \ + -v cpm-data:/data \ + caddyproxymanager +``` + +Published images are available at `ghcr.io/pacerino/caddyproxymanager`. + +## Development & testing + +```bash +# Backend tests +cd backend && go test ./... + +# Frontend type-check + build +cd frontend && npm run build +``` + +CI (GitHub Actions, `.github/workflows/ci.yml`) runs the backend tests with the +race detector, builds the frontend, and on pushes to `master` / version tags +builds and publishes the Docker image to GHCR. ## Contribution If you want to help with the development pull requests etc. are welcome! \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..efe492e --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -e + +# Start Caddy in the background with just its admin API enabled. CPM (in the +# default "api" mode) then manages the HTTP config through that admin API. +# Mount a custom config at /data/caddy.json to override this behaviour. +CADDY_CONFIG="${CADDY_CONFIG:-/data/caddy.json}" + +mkdir -p "$(dirname "$CADDY_CONFIG")" +if [ ! -f "$CADDY_CONFIG" ]; then + cat > "$CADDY_CONFIG" <<'JSON' +{ + "admin": { "listen": "localhost:2019" }, + "apps": { "http": { "servers": {} } } +} +JSON +fi + +echo "Starting Caddy with admin API…" +caddy run --config "$CADDY_CONFIG" & +CADDY_PID=$! + +# Stop Caddy when CPM exits. +trap 'kill "$CADDY_PID" 2>/dev/null || true' EXIT INT TERM + +# Give the admin API a moment to come up. +sleep 1 + +echo "Starting Caddy Proxy Manager…" +exec /usr/local/bin/cpm