From 1efb98f792298da91a4dd9b871f90a713b70937e Mon Sep 17 00:00:00 2001 From: Lftobs Date: Mon, 15 Jun 2026 11:07:01 +0100 Subject: [PATCH 1/4] feat: add configurable CADDY_BASE_DOMAIN for public ingress with auto-SSL - Add CADDY_BASE_DOMAIN env var (default: localhost) for deployment subdomains - Add CADDY_EMAIL env var for Let's Encrypt SSL notifications - Add :443 HTTPS listener to Caddyfile with auto-SSL - Update domain-verifier, runtime, scaling engine, and projects routes to use configurable base domain instead of hardcoded .localhost - Omit :80 port suffix for real domains so Caddy auto-provisions TLS - Update docker-compose.yml with new env vars and port mapping - Document new configuration options in AGENTS.md and README.md --- AGENTS.md | 2 ++ README.md | 12 +++++--- apps/api/bun.lock | 1 + apps/api/src/api/projects/index.ts | 6 ++-- apps/api/src/orchestrator/runtime.ts | 3 +- apps/api/src/scaling/engine.ts | 3 +- .../utils/__tests__/domain-verifier.test.ts | 2 +- apps/api/src/utils/config-loader.ts | 1 + apps/api/src/utils/config.ts | 1 + apps/api/src/utils/domain-verifier.ts | 3 +- docker-compose.yml | 4 +++ infra/caddy/Caddyfile | 29 ++++++++++++++++++- 12 files changed, 55 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7640621..94b30d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,6 +179,8 @@ Requires secrets: `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID` | `DATABASE_PATH` | `./data/dequel.db` | SQLite database | | `WORKSPACE_ROOT` | `./workspace` | Build staging | | `CADDY_ROUTES_DIR` | `./infra/caddy/routes` | Caddy route output | +| `CADDY_BASE_DOMAIN` | `localhost` | Base domain for deployment subdomains. Set to a real domain (e.g. `example.com`) for Let's Encrypt auto-SSL. | +| `CADDY_EMAIL` | _(empty)_ | Email for Let's Encrypt SSL certificate notifications | | `DOCKER_NETWORK` | `dequel_net` | Docker network for deployments | | `BUILDKIT_HOST` | `tcp://buildkit:1234` | Buildkit daemon | | `RAILPACK_BUILD_TIMEOUT_MS` | `1200000` | Build timeout | diff --git a/README.md b/README.md index 6bbcfb6..3987f05 100644 --- a/README.md +++ b/README.md @@ -148,19 +148,23 @@ Key environment variables for the API service: | `DATABASE_PATH` | `./data/dequel.db` | SQLite database location | | `WORKSPACE_ROOT` | `./workspace` | Build staging directory | | `CADDY_ROUTES_DIR` | `./infra/caddy/routes` | Caddy route output | +| `CADDY_BASE_DOMAIN` | `localhost` | Base domain for deployment subdomains. Set to a real domain (e.g. `example.com`) for Let's Encrypt auto-SSL. | +| `CADDY_EMAIL` | _(empty)_ | Email for Let's Encrypt SSL certificate notifications | | `DOCKER_NETWORK` | `dequel_net` | Docker network for deployments | | `BUILDKIT_HOST` | `tcp://buildkit:1234` | Buildkit daemon address | | `RAILPACK_BUILD_TIMEOUT_MS` | `1200000` | Build timeout | ## Deployment Ingress -Deployed applications get a subdomain under `localhost`: +Deployed applications get a subdomain under the configured base domain: ``` -http://.localhost +https://. ``` -For production, set `CADDY_BASE_DOMAIN` and configure DNS. Caddy -auto-provisions SSL via Let's Encrypt. +When `CADDY_BASE_DOMAIN=localhost` (default), apps are accessible via HTTP at +`http://.localhost`. For production, set `CADDY_BASE_DOMAIN` to your +domain (e.g. `example.com`) and configure a wildcard DNS `*.example.com` +pointing to your server. Caddy auto-provisions SSL via Let's Encrypt. ## Design Decisions diff --git a/apps/api/bun.lock b/apps/api/bun.lock index 671d0a8..1477787 100644 --- a/apps/api/bun.lock +++ b/apps/api/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "dequel-api", diff --git a/apps/api/src/api/projects/index.ts b/apps/api/src/api/projects/index.ts index d7f0885..11473a7 100644 --- a/apps/api/src/api/projects/index.ts +++ b/apps/api/src/api/projects/index.ts @@ -121,7 +121,7 @@ export const projectsRoutes = new Elysia() } const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63); const slug = slugify(project.name); - const domains = [`${slug}.localhost`]; + const domains = [`${slug}.${config.caddyBaseDomain}`]; const projectDomains = await listDomains(id); const verified = projectDomains.filter(d => d.validationStatus === 'verified'); for (const d of verified) { @@ -197,7 +197,7 @@ export const projectsRoutes = new Elysia() } const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63); const slug = slugify(project.name); - const domains = [`${slug}.localhost`]; + const domains = [`${slug}.${config.caddyBaseDomain}`]; const projectDomains = await listDomains(id); const verified = projectDomains.filter(d => d.validationStatus === 'verified'); for (const d of verified) { @@ -250,7 +250,7 @@ export const projectsRoutes = new Elysia() const encoder = new TextEncoder(); const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63); const slug = slugify(project.name); - const domains = [`${slug}.localhost`]; + const domains = [`${slug}.${config.caddyBaseDomain}`]; const projectDomains = await listDomains(id); const verified = projectDomains.filter(d => d.validationStatus === 'verified'); for (const d of verified) { diff --git a/apps/api/src/orchestrator/runtime.ts b/apps/api/src/orchestrator/runtime.ts index 95c3d96..22e7422 100644 --- a/apps/api/src/orchestrator/runtime.ts +++ b/apps/api/src/orchestrator/runtime.ts @@ -103,7 +103,8 @@ export const deployContainer = async ( const slug = slugify(opts.projectName || opts.projectId || deploymentId); const shortId = deploymentId.slice(0, 8); const containerName = `${slug}-${shortId}`; - const liveUrl = `http://${slug}.localhost`; + const scheme = config.caddyBaseDomain === 'localhost' ? 'http' : 'https'; + const liveUrl = `${scheme}://${slug}.${config.caddyBaseDomain}`; await onLog(`Starting container ${containerName} from image ${imageTag}`); diff --git a/apps/api/src/scaling/engine.ts b/apps/api/src/scaling/engine.ts index ea17153..510d5f3 100644 --- a/apps/api/src/scaling/engine.ts +++ b/apps/api/src/scaling/engine.ts @@ -278,7 +278,8 @@ class ScalingEngine { targets.push(`deploy-${dep.id}-replica-${i}:${port}`); } - const caddySnippet = `${slug}.localhost:80 {\n reverse_proxy ${targets.join(' ')}\n}\n`; + const baseDomain = config.caddyBaseDomain === 'localhost' ? `${config.caddyBaseDomain}:80` : config.caddyBaseDomain; + const caddySnippet = `${slug}.${baseDomain} {\n reverse_proxy ${targets.join(' ')}\n}\n`; await writeFile(routeFile, caddySnippet, 'utf8'); // Reload Caddy diff --git a/apps/api/src/utils/__tests__/domain-verifier.test.ts b/apps/api/src/utils/__tests__/domain-verifier.test.ts index 686b4b6..42cb577 100644 --- a/apps/api/src/utils/__tests__/domain-verifier.test.ts +++ b/apps/api/src/utils/__tests__/domain-verifier.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; let tmpDir: string; let routesDir: string; -const testConfig = { caddyRoutesDir: '', appInternalPort: 3000 }; +const testConfig = { caddyRoutesDir: '', appInternalPort: 3000, caddyBaseDomain: 'localhost' }; const fileUrl = (path: string) => new URL(path, import.meta.url).toString(); mock.module(fileUrl('../config'), () => ({ config: testConfig })); diff --git a/apps/api/src/utils/config-loader.ts b/apps/api/src/utils/config-loader.ts index 515ab90..3b8d894 100644 --- a/apps/api/src/utils/config-loader.ts +++ b/apps/api/src/utils/config-loader.ts @@ -10,6 +10,7 @@ export interface FileConfig { workspaceRoot?: string; caddyRoutesDir?: string; caddyIngressBase?: string; + caddyBaseDomain?: string; dockerNetwork?: string; appInternalPort?: number; buildkitHost?: string; diff --git a/apps/api/src/utils/config.ts b/apps/api/src/utils/config.ts index c6194ca..7007833 100644 --- a/apps/api/src/utils/config.ts +++ b/apps/api/src/utils/config.ts @@ -22,6 +22,7 @@ export const config = { workspaceRoot: withFile("WORKSPACE_ROOT", "/app/workspace"), caddyRoutesDir: withFile("CADDY_ROUTES_DIR", "/caddy/routes"), caddyIngressBase: withFile("CADDY_INGRESS_BASE", "http://localhost"), + caddyBaseDomain: withFile("CADDY_BASE_DOMAIN", "localhost"), dockerNetwork: withFile("DOCKER_NETWORK", "dequel_net"), appInternalPort: withFile("APP_INTERNAL_PORT", "3000", Number), buildkitHost: withFile("BUILDKIT_HOST", "tcp://buildkit:1234"), diff --git a/apps/api/src/utils/domain-verifier.ts b/apps/api/src/utils/domain-verifier.ts index 99360c5..4f0877a 100644 --- a/apps/api/src/utils/domain-verifier.ts +++ b/apps/api/src/utils/domain-verifier.ts @@ -118,7 +118,8 @@ export const buildCaddySnippet = async ( listDomainsFn: typeof listDomains = listDomains, appPort?: number, ): Promise => { - let domains = [`${slug}.localhost:80`]; + const baseDomain = config.caddyBaseDomain === 'localhost' ? `${config.caddyBaseDomain}:80` : config.caddyBaseDomain; + let domains = [`${slug}.${baseDomain}`]; let port = appPort ?? config.appInternalPort; if (projectId) { diff --git a/docker-compose.yml b/docker-compose.yml index 55f1167..99ddb1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,7 @@ services: WORKSPACE_ROOT: /app/workspace CADDY_ROUTES_DIR: /caddy/routes CADDY_INGRESS_BASE: http://localhost + CADDY_BASE_DOMAIN: ${CADDY_BASE_DOMAIN:-localhost} DOCKER_NETWORK: dequel_net APP_INTERNAL_PORT: 3000 BUILDKIT_HOST: tcp://buildkit:1234 @@ -103,8 +104,11 @@ services: "caddyfile", "--watch", ] + environment: + CADDY_EMAIL: ${CADDY_EMAIL:-} ports: - "80:80" + - "443:443" volumes: - ./infra/caddy/Caddyfile:/etc/caddy/Caddyfile:ro - ./infra/caddy/routes:/etc/caddy/routes diff --git a/infra/caddy/Caddyfile b/infra/caddy/Caddyfile index fcd8fd5..6b34c41 100644 --- a/infra/caddy/Caddyfile +++ b/infra/caddy/Caddyfile @@ -1,5 +1,5 @@ { - email support@mini-cms.xyz + email {$CADDY_EMAIL} } import /etc/caddy/routes/*.caddy @@ -30,3 +30,30 @@ import /etc/caddy/routes/*.caddy reverse_proxy web:3000 } } + +{$CADDY_BASE_DOMAIN:localhost}:443 { + log { + output stdout + format json + } + + encode zstd gzip + + handle /api/* { + reverse_proxy api:3001 + } + + handle /metrics* { + reverse_proxy prometheus:9090 + } + + redir /grafana /grafana/ + + handle /grafana/* { + reverse_proxy grafana:3000 + } + + handle { + reverse_proxy web:3000 + } +} From 8132440e5778c6d8659b7221c634978539dcb2d3 Mon Sep 17 00:00:00 2001 From: Lftobs Date: Mon, 15 Jun 2026 11:24:59 +0100 Subject: [PATCH 2/4] chore: remove unused CADDY_INGRESS_BASE config (superseded by CADDY_BASE_DOMAIN) --- apps/api/src/utils/config-loader.ts | 1 - apps/api/src/utils/config.ts | 1 - docker-compose.yml | 1 - 3 files changed, 3 deletions(-) diff --git a/apps/api/src/utils/config-loader.ts b/apps/api/src/utils/config-loader.ts index 3b8d894..f8155c7 100644 --- a/apps/api/src/utils/config-loader.ts +++ b/apps/api/src/utils/config-loader.ts @@ -9,7 +9,6 @@ export interface FileConfig { databasePath?: string; workspaceRoot?: string; caddyRoutesDir?: string; - caddyIngressBase?: string; caddyBaseDomain?: string; dockerNetwork?: string; appInternalPort?: number; diff --git a/apps/api/src/utils/config.ts b/apps/api/src/utils/config.ts index 7007833..ae166e8 100644 --- a/apps/api/src/utils/config.ts +++ b/apps/api/src/utils/config.ts @@ -21,7 +21,6 @@ export const config = { databasePath: withFile("DATABASE_PATH", "/app/data/dequel.db"), workspaceRoot: withFile("WORKSPACE_ROOT", "/app/workspace"), caddyRoutesDir: withFile("CADDY_ROUTES_DIR", "/caddy/routes"), - caddyIngressBase: withFile("CADDY_INGRESS_BASE", "http://localhost"), caddyBaseDomain: withFile("CADDY_BASE_DOMAIN", "localhost"), dockerNetwork: withFile("DOCKER_NETWORK", "dequel_net"), appInternalPort: withFile("APP_INTERNAL_PORT", "3000", Number), diff --git a/docker-compose.yml b/docker-compose.yml index 99ddb1c..9eeb8a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,6 @@ services: DATABASE_PATH: /app/data/dequel.db WORKSPACE_ROOT: /app/workspace CADDY_ROUTES_DIR: /caddy/routes - CADDY_INGRESS_BASE: http://localhost CADDY_BASE_DOMAIN: ${CADDY_BASE_DOMAIN:-localhost} DOCKER_NETWORK: dequel_net APP_INTERNAL_PORT: 3000 From 64c331af665d75f5f3015386f26bccd6f5c5237c Mon Sep 17 00:00:00 2001 From: Lftobs Date: Mon, 15 Jun 2026 11:36:37 +0100 Subject: [PATCH 3/4] docs: update installation, quickstart, and system-config for CADDY_BASE_DOMAIN --- apps/docs/src/content/docs/installation.md | 20 ++++++++++++++++++-- apps/docs/src/content/docs/quickstart.md | 2 +- apps/docs/src/content/docs/system-config.md | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/docs/src/content/docs/installation.md b/apps/docs/src/content/docs/installation.md index 509fca0..5fda364 100644 --- a/apps/docs/src/content/docs/installation.md +++ b/apps/docs/src/content/docs/installation.md @@ -32,7 +32,7 @@ After installation, start the platform: dequel start ``` -Open `http://localhost` to access the dashboard. +Open `http://localhost` to access the dashboard (or the configured `CADDY_BASE_DOMAIN` in production). ## Manual Setup (no install script) @@ -64,7 +64,7 @@ curl -fsSL https://raw.githubusercontent.com/Lftobs/dequel/main/infra/monitoring docker compose -f ~/.dequel/docker-compose.yml up -d ``` -The compose file uses pre-built images from GitHub Container Registry, so no source code checkout is needed. Access the dashboard at `http://localhost`. +The compose file uses pre-built images from GitHub Container Registry, so no source code checkout is needed. Access the dashboard at `http://localhost` (or your configured `CADDY_BASE_DOMAIN`). ## Manual Setup with Docker Compose (with source) @@ -76,6 +76,21 @@ cd dequel docker compose up -d --build ``` +## Production Setup + +To make Dequel publicly accessible with auto-SSL: + +1. Set `CADDY_BASE_DOMAIN` and `CADDY_EMAIL` in `~/.dequel/.env`: + ``` + CADDY_BASE_DOMAIN=example.com + CADDY_EMAIL=admin@example.com + ``` +2. Configure a wildcard DNS `*.example.com` pointing to your server's IP. +3. Ensure ports `80` and `443` are open in your firewall. +4. Restart Dequel: `dequel restart` + +Deployed apps will be reachable at `https://.example.com` with auto-provisioned Let's Encrypt certificates. The dashboard is available at both `http://example.com` and `https://example.com`. + ## The `dequel` CLI The `dequel` command manages the platform lifecycle: @@ -119,6 +134,7 @@ For local development, run the API and web dashboard directly: export DATABASE_PATH=./data/dequel.db \ WORKSPACE_ROOT=./workspace \ CADDY_ROUTES_DIR=./infra/caddy/routes \ + CADDY_BASE_DOMAIN=localhost \ DOCKER_NETWORK=dequel_net \ APP_INTERNAL_PORT=3000 bun apps/api/src/index.ts diff --git a/apps/docs/src/content/docs/quickstart.md b/apps/docs/src/content/docs/quickstart.md index 5aee2d7..00ea75d 100644 --- a/apps/docs/src/content/docs/quickstart.md +++ b/apps/docs/src/content/docs/quickstart.md @@ -9,7 +9,7 @@ In this guide, we'll configure a project, push the codebase, and verify that the ## Step 1: Create a Project -Navigate to the Dequel dashboard (normally hosted at `localhost:5173`) and click the **+ Create Project** button in the upper right corner. +Navigate to the Dequel dashboard (at `http://localhost` or your configured `CADDY_BASE_DOMAIN`) and click the **+ Create Project** button in the upper right corner. - Enter a unique project name (e.g. `my-web-app`). - Configure CPU limit boundaries (e.g., `0.5 cores`) and Memory bounds (e.g., `512MB`). diff --git a/apps/docs/src/content/docs/system-config.md b/apps/docs/src/content/docs/system-config.md index 7e08cca..1381ba3 100644 --- a/apps/docs/src/content/docs/system-config.md +++ b/apps/docs/src/content/docs/system-config.md @@ -74,6 +74,24 @@ Config file equivalent: On boot, these values seed the `smtp_settings` table. The password is encrypted at rest using `ENV_ENCRYPTION_KEY`. You can also update these from the Settings page in the dashboard, and send a test email to verify the configuration. +### Ingress + +| Variable | Default | Description | +|----------|---------|-------------| +| `CADDY_BASE_DOMAIN` | `localhost` | Base domain for deployment subdomains. Deployed apps are reachable at `https://.`. Set to a real domain (e.g. `example.com`) and configure wildcard DNS for auto-SSL. | +| `CADDY_EMAIL` | `""` | Email address for Let's Encrypt SSL certificate expiration notifications | +| `PUBLIC_URL` | `http://localhost` | Externally reachable URL for GitHub webhook callbacks | + +Config file equivalent: + +```json +{ + "caddyBaseDomain": "example.com", + "caddyEmail": "admin@example.com", + "publicUrl": "https://example.com" +} +``` + ## Full Config File Example ```json From 0392fe8e61b14293af7116a8c9f2fe9f3674ce4c Mon Sep 17 00:00:00 2001 From: Lftobs Date: Mon, 15 Jun 2026 12:36:01 +0100 Subject: [PATCH 4/4] refactor: derive PUBLIC_URL from CADDY_BASE_DOMAIN, add to Caddy service --- apps/api/src/api/github/index.ts | 6 ++++-- apps/api/src/utils/config.ts | 1 - apps/docs/src/content/docs/system-config.md | 6 ++---- docker-compose.yml | 1 + 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/api/src/api/github/index.ts b/apps/api/src/api/github/index.ts index 9d45c9a..b2b6e83 100644 --- a/apps/api/src/api/github/index.ts +++ b/apps/api/src/api/github/index.ts @@ -198,7 +198,8 @@ export const githubRoutes = new Elysia({ prefix: "/github" }) set.status = 401; return { error: "Not authenticated" }; } - const webhookUrl = `${config.publicUrl}/api/github/webhook`; + const baseUrl = config.caddyBaseDomain === 'localhost' ? 'http://localhost' : `https://${config.caddyBaseDomain}`; + const webhookUrl = `${baseUrl}/api/github/webhook`; const integration = await getGithubIntegration(); const secret = integration?.webhookSecret || config.githubWebhookSecret; @@ -229,7 +230,8 @@ export const githubRoutes = new Elysia({ prefix: "/github" }) set.status = 401; return { error: "Not authenticated" }; } - const webhookUrl = `${config.publicUrl}/api/github/webhook`; + const baseUrl = config.caddyBaseDomain === 'localhost' ? 'http://localhost' : `https://${config.caddyBaseDomain}`; + const webhookUrl = `${baseUrl}/api/github/webhook`; const hooks = await fetchGitHub(`/repos/${params.owner}/${params.repo}/hooks`, token); const existing = Array.isArray(hooks) ? hooks.find((h: any) => h.config?.url === webhookUrl) : null; if (!existing) { diff --git a/apps/api/src/utils/config.ts b/apps/api/src/utils/config.ts index ae166e8..8c6772e 100644 --- a/apps/api/src/utils/config.ts +++ b/apps/api/src/utils/config.ts @@ -36,7 +36,6 @@ export const config = { smtpPass: withFile("SMTP_PASS", ""), smtpFrom: withFile("SMTP_FROM", "dequel@localhost"), alertEvalIntervalMs: withFile("ALERT_EVAL_INTERVAL_MS", "60000", Number), - publicUrl: withFile("PUBLIC_URL", "http://localhost"), githubClientId: withFile("GITHUB_CLIENT_ID", ""), githubClientSecret: withFile("GITHUB_CLIENT_SECRET", ""), githubAppName: withFile("GITHUB_APP_NAME", "Dequel"), diff --git a/apps/docs/src/content/docs/system-config.md b/apps/docs/src/content/docs/system-config.md index 1381ba3..989c4af 100644 --- a/apps/docs/src/content/docs/system-config.md +++ b/apps/docs/src/content/docs/system-config.md @@ -78,17 +78,15 @@ On boot, these values seed the `smtp_settings` table. The password is encrypted | Variable | Default | Description | |----------|---------|-------------| -| `CADDY_BASE_DOMAIN` | `localhost` | Base domain for deployment subdomains. Deployed apps are reachable at `https://.`. Set to a real domain (e.g. `example.com`) and configure wildcard DNS for auto-SSL. | +| `CADDY_BASE_DOMAIN` | `localhost` | Base domain for deployment subdomains. Deployed apps are reachable at `https://.`. GitHub webhook URLs are derived from this automatically. Set to a real domain (e.g. `example.com`) and configure wildcard DNS for auto-SSL. | | `CADDY_EMAIL` | `""` | Email address for Let's Encrypt SSL certificate expiration notifications | -| `PUBLIC_URL` | `http://localhost` | Externally reachable URL for GitHub webhook callbacks | Config file equivalent: ```json { "caddyBaseDomain": "example.com", - "caddyEmail": "admin@example.com", - "publicUrl": "https://example.com" + "caddyEmail": "admin@example.com" } ``` diff --git a/docker-compose.yml b/docker-compose.yml index 9eeb8a9..df049a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -105,6 +105,7 @@ services: ] environment: CADDY_EMAIL: ${CADDY_EMAIL:-} + CADDY_BASE_DOMAIN: ${CADDY_BASE_DOMAIN:-localhost} ports: - "80:80" - "443:443"