From 1659f662fabf50a442ba17352a283b891eebea55 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Mon, 22 Jun 2026 13:54:05 +0000 Subject: [PATCH 1/3] refactor: migrate from nginx to Caddy as the gateway - Removed nginx gateway configuration file and references throughout the project. - Updated documentation to reflect the change from nginx to Caddy, including repository structure, service boundaries, and getting started guides. - Modified installation and upgrade scripts to download and configure Caddy instead of nginx. - Adjusted environment variables and configuration settings in the ai-agent and realtime services to point to the new Caddy gateway. - Updated service documentation to indicate the new Caddy gateway endpoints and behavior. --- .github/workflows/cd.yml | 26 +- README.md | 14 +- ROADMAP.md | 2 +- SECURITY.md | 2 +- apps/mcp/src/plugin-loader.ts | 2 +- apps/mcp/src/types/index.ts | 2 +- apps/web/Caddyfile | 12 + apps/web/Dockerfile | 10 +- apps/web/nginx.conf | 22 -- deploy/.env.production.example | 2 +- deploy/README.md | 67 ++++- deploy/caddy/Caddyfile | 137 +++++++++++ deploy/caddy/Caddyfile.dev | 80 ++++++ deploy/docker-compose.dev.yml | 10 +- deploy/docker-compose.e2e.yml | 6 +- deploy/docker-compose.prod.yml | 28 ++- deploy/nginx/gateway.conf | 275 --------------------- deploy/nginx/gateway.dev.conf | 282 ---------------------- docs/architecture/repository-structure.md | 4 +- docs/architecture/service-boundaries.md | 2 +- docs/guides/getting-started.md | 26 +- docs/guides/local-development.md | 4 +- scripts/install.sh | 72 ++++-- scripts/upgrade.sh | 253 +++++++++++++++++++ services/ai-agent/src/config.py | 2 +- services/realtime/.env.example | 2 +- services/realtime/README.md | 4 +- services/realtime/src/server.ts | 2 +- 28 files changed, 688 insertions(+), 662 deletions(-) create mode 100644 apps/web/Caddyfile delete mode 100644 apps/web/nginx.conf create mode 100644 deploy/caddy/Caddyfile create mode 100644 deploy/caddy/Caddyfile.dev delete mode 100644 deploy/nginx/gateway.conf delete mode 100644 deploy/nginx/gateway.dev.conf create mode 100755 scripts/upgrade.sh diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index a2034753..9c798b23 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -9,16 +9,17 @@ name: cd # /paca-api: — Go API service Docker image # ghcr.io/paca-ai/paca-realtime: — Node realtime service Docker image # /paca-realtime: — Node realtime service Docker image -# ghcr.io/paca-ai/paca-web: — Web app Docker image (nginx) -# /paca-web: — Web app Docker image (nginx) +# ghcr.io/paca-ai/paca-web: — Web app Docker image (Caddy) +# /paca-web: — Web app Docker image (Caddy) # ghcr.io/paca-ai/paca-ai-agent: — AI Agent service Docker image # /paca-ai-agent: — AI Agent service Docker image # registry.npmjs.org @paca-ai/paca-mcp — MCP server npm package # registry.npmjs.org @paca-ai/plugin-sdk-react — Plugin frontend SDK npm package # GitHub Release assets: # install.sh — one-shot interactive install script +# upgrade.sh — upgrades an existing installation in place # docker-compose.yml — standalone compose (no source tree required) -# gateway.conf — nginx gateway configuration +# Caddyfile — Caddy gateway configuration on: release: @@ -138,9 +139,9 @@ jobs: # ───────────────────────────────────────────────────────────────────────────── # Web app image # ───────────────────────────────────────────────────────────────────────────── - # The pre-built image uses relative URLs that work with nginx /api proxy - # out of the box, since the API client now derives the base URL from - # window.location.origin at runtime. + # The pre-built image uses relative URLs that work with the Caddy /api + # proxy out of the box, since the API client now derives the base URL + # from window.location.origin at runtime. web-image: name: Build and push Web image runs-on: ubuntu-latest @@ -313,10 +314,11 @@ jobs: # ───────────────────────────────────────────────────────────────────────────── # Deployment assets → GitHub Release # - # Uploads three files so that users can run Paca without cloning the repo: + # Uploads four files so that users can run or upgrade Paca without cloning the repo: # install.sh — interactive setup wizard (download + configure + start) + # upgrade.sh — upgrades an existing installation in place # docker-compose.yml — standalone compose referencing pre-built DockerHub images - # gateway.conf — nginx gateway configuration required by the compose file + # Caddyfile — Caddy gateway configuration required by the compose file # # End-users download and run: # curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/install.sh -o install.sh @@ -337,9 +339,10 @@ jobs: run: | # Rename files to the names end-users will download. cp deploy/docker-compose.prod.yml docker-compose.yml - cp deploy/nginx/gateway.conf gateway.conf + cp deploy/caddy/Caddyfile Caddyfile cp scripts/install.sh install.sh - chmod +x install.sh + cp scripts/upgrade.sh upgrade.sh + chmod +x install.sh upgrade.sh - name: Upload assets to GitHub Release env: @@ -347,6 +350,7 @@ jobs: run: | gh release upload "${{ github.event.release.tag_name }}" \ install.sh \ + upgrade.sh \ docker-compose.yml \ - gateway.conf \ + Caddyfile \ --clobber diff --git a/README.md b/README.md index 6d1a009b..cbbcf3ef 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,8 @@ The script walks you through configuration interactively and starts the full sta ```bash mkdir paca && cd paca curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/docker-compose.yml -o docker-compose.yml -mkdir -p nginx -curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/gateway.conf -o nginx/gateway.conf +mkdir -p caddy +curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/Caddyfile -o caddy/Caddyfile ``` **2. Download the environment template** @@ -250,14 +250,16 @@ docker compose --env-file .env up -d ### Upgrading to a new version -From the directory where your `docker-compose.yml` lives: +From the directory where your `docker-compose.yml` and `.env` live, run the upgrade +script published with each release — it refreshes `docker-compose.yml` and the +Caddyfile (with backups) and restarts the stack: ```bash -docker compose pull -docker compose --env-file .env up -d +curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/upgrade.sh -o upgrade.sh +bash upgrade.sh ``` -Database migrations run automatically on API startup. +Database migrations run automatically on API startup. See [deploy/README.md](deploy/README.md#upgrading-to-a-new-version) for pinning a specific version or passing through `--scale` flags. --- diff --git a/ROADMAP.md b/ROADMAP.md index ddfc35ab..8c639ea5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ _Goal: a working, self-hostable core that a small team can actually use._ - ✅ Docker Compose single-command setup - ✅ Interactive install script for Linux servers - ✅ PostgreSQL + Valkey bundled by default -- ✅ Nginx gateway with service routing +- ✅ Caddy gateway with service routing - ✅ Environment-based configuration (`.env`) ### Core Platform diff --git a/SECURITY.md b/SECURITY.md index 4e69d678..6cf246ac 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,7 +19,7 @@ Security reports may cover: - Unsafe AI agent actions or privilege escalation. - WASM plugin sandbox escapes or capability bypasses. - Supply chain or dependency risks. -- Deployment misconfiguration risks (Docker Compose, nginx, environment variables). +- Deployment misconfiguration risks (Docker Compose, Caddy, environment variables). - API injection risks (SQL injection, command injection, XSS). ## Supported Versions diff --git a/apps/mcp/src/plugin-loader.ts b/apps/mcp/src/plugin-loader.ts index e426645c..59c31f54 100644 --- a/apps/mcp/src/plugin-loader.ts +++ b/apps/mcp/src/plugin-loader.ts @@ -179,7 +179,7 @@ export async function loadPlugins(config: PacaConfig): Promise { for (const plugin of mcpPlugins) { // biome-ignore lint/style/noNonNullAssertion: filtered above const url = plugin.manifest.mcp!.remoteEntryUrl; - // Use gatewayURL when set — MCP bundles are served by the gateway (nginx), + // Use gatewayURL when set — MCP bundles are served by the gateway (Caddy), // not the API service, so relative URLs must be resolved against the gateway. const pluginBaseURL = config.gatewayURL ?? config.baseURL; try { diff --git a/apps/mcp/src/types/index.ts b/apps/mcp/src/types/index.ts index cacf9106..e3ae3562 100644 --- a/apps/mcp/src/types/index.ts +++ b/apps/mcp/src/types/index.ts @@ -5,7 +5,7 @@ export interface PacaConfig { * Base URL used to resolve plugin MCP entry URLs (e.g. relative paths like * `/plugins-mcp//mcp.js`). Defaults to `baseURL` when not set. * - * In Docker deployments the MCP bundles are served by the gateway (nginx), + * In Docker deployments the MCP bundles are served by the gateway (Caddy), * not by the API service, so this should be set to the gateway's internal * URL (e.g. `http://gateway`). */ diff --git a/apps/web/Caddyfile b/apps/web/Caddyfile new file mode 100644 index 00000000..bbbf9837 --- /dev/null +++ b/apps/web/Caddyfile @@ -0,0 +1,12 @@ +:3000 { + root * /srv + header -Server + + # Vite emits hashed filenames under /assets — cache aggressively + @assets path /assets/* + header @assets Cache-Control "public, max-age=31536000, immutable" + + # All other routes fall back to index.html for client-side routing + try_files {path} /index.html + file_server +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index d57b3b2c..f9ec1d4c 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -14,14 +14,14 @@ COPY . . RUN bun run build # ── Runtime stage ───────────────────────────────────────────────────────────── -FROM nginx:1.29-alpine +FROM caddy:2-alpine -# Replace the default nginx config with our SPA config -COPY nginx.conf /etc/nginx/conf.d/default.conf +# Replace the default Caddy config with our SPA config +COPY Caddyfile /etc/caddy/Caddyfile # Copy built assets from the build stage -COPY --from=builder /build/dist /usr/share/nginx/html +COPY --from=builder /build/dist /srv EXPOSE 3000 -CMD ["nginx", "-g", "daemon off;"] +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf deleted file mode 100644 index 3e2c81f4..00000000 --- a/apps/web/nginx.conf +++ /dev/null @@ -1,22 +0,0 @@ -server { - listen 3000; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - # Security: hide nginx version - server_tokens off; - - # Vite emits hashed filenames under /assets — cache aggressively - location /assets/ { - expires 1y; - add_header Cache-Control "public, immutable"; - access_log off; - } - - # All other routes fall back to index.html for client-side routing - location / { - try_files $uri $uri/ /index.html; - } -} diff --git a/deploy/.env.production.example b/deploy/.env.production.example index b788c3e7..763cf5f3 100644 --- a/deploy/.env.production.example +++ b/deploy/.env.production.example @@ -54,7 +54,7 @@ ADMIN_PASSWORD=replace-with-a-strong-admin-password # MinIO is started as a sidecar container using the credentials below. STORAGE_PROVIDER=minio STORAGE_ENDPOINT=minio:9000 -# Public URL through which browsers reach the object store via the nginx gateway. +# Public URL through which browsers reach the object store via the Caddy gateway. # Update to match your domain when deploying behind a real hostname. STORAGE_PUBLIC_URL=http://localhost/storage STORAGE_REGION=us-east-1 diff --git a/deploy/README.md b/deploy/README.md index 9b962217..d8ef5874 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -24,9 +24,9 @@ Service container definitions live with each service: ### Recommended: install script The easiest way to run Paca without cloning the repository is via the install script -published with each release. It downloads the compose file and nginx config, walks you -through configuration interactively (database, storage, AI agent), generates a `.env` -with strong random secrets, and starts the stack. +published with each release. It downloads the compose file and Caddyfile, walks you +through configuration interactively (database, storage, networking/HTTPS, AI agent), +generates a `.env` with strong random secrets, and starts the stack. ```bash curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/install.sh -o install.sh @@ -41,6 +41,7 @@ The installer supports: | External PostgreSQL | Supply a `DATABASE_URL`; postgres container is suppressed | | Self-hosted MinIO | Starts a MinIO container for S3-compatible file storage (default) | | AWS S3 | Supply AWS credentials; MinIO container is suppressed | +| HTTPS | Enabled by default — Let's Encrypt for a real domain, Caddy's local CA otherwise; can be disabled for plain HTTP | | AI Agent | Enabled by default; can be skipped to reduce resource usage | ### Manual setup @@ -48,9 +49,9 @@ The installer supports: Download the two required files from the latest release: ```bash -mkdir -p paca/nginx && cd paca +mkdir -p paca/caddy && cd paca curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/docker-compose.yml -o docker-compose.yml -curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/gateway.conf -o nginx/gateway.conf +curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/Caddyfile -o caddy/Caddyfile ``` Download the example environment file and edit it: @@ -82,6 +83,34 @@ Start the full stack (bundled PostgreSQL + MinIO): docker compose --env-file .env up -d ``` +**With HTTPS** — set `SITE_ADDRESS` to any concrete domain or IP address and Caddy +handles certificates automatically, choosing the right kind for what you give it: + +```bash +# In .env: set SITE_ADDRESS to your domain/IP, and PUBLIC_URL/COOKIE_SECURE to match. +SITE_ADDRESS=paca.example.com +PUBLIC_URL=https://paca.example.com +COOKIE_SECURE=true +``` + +```bash +docker compose --env-file .env up -d +``` + +- A real domain name with DNS already pointed here gets a trusted Let's Encrypt + certificate, renewed automatically. Ports 80 and 443 must both be reachable from the + internet for the ACME challenge to succeed. +- An IP address, `localhost`, `*.localhost`, or anything else that isn't a publicly + resolvable domain gets a certificate from Caddy's own local certificate authority + instead — traffic is still encrypted, but browsers will show a trust warning since + that CA isn't publicly trusted. + +Either way, certificates persist in the `caddy_data` volume across restarts. + +Without `SITE_ADDRESS` (or set to a bare port like `:80`), the gateway serves plain +HTTP — the simplest option, and the right one when another proxy or load balancer in +front of this server already terminates TLS. + **With external PostgreSQL** (suppress the bundled container): ```bash @@ -110,7 +139,31 @@ docker compose --env-file .env up -d --scale postgres=0 --scale minio=0 ### Upgrading to a new version -Pull the latest images and restart the stack: +**Recommended: upgrade script.** From the directory where your `docker-compose.yml` and +`.env` live, run the same upgrade script published with each release. It backs up +`docker-compose.yml`, `caddy/Caddyfile`, and `.env` before overwriting them, refreshes +the compose file and Caddyfile, re-pins image versions when you request a specific +release, then pulls and restarts the stack: + +```bash +curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/upgrade.sh -o upgrade.sh +bash upgrade.sh +``` + +Pin to a specific release instead of `latest`: + +```bash +PACA_VERSION=v1.2.3 bash upgrade.sh +``` + +Pass through any `--scale` flags you used originally: + +```bash +bash upgrade.sh --scale web=0 --scale minio=0 +``` + +**Manual:** pull the latest images and restart the stack — this is enough when +`docker-compose.yml` and the Caddyfile haven't changed shape since your last upgrade: ```bash docker compose pull @@ -192,7 +245,7 @@ and use Docker Compose only for PostgreSQL and Valkey. | Service | Port | Notes | |---|---|---| -| Gateway (nginx) | **3000** | Main entry point — `http://localhost:3000` | +| Gateway (Caddy) | **3000** | Main entry point — `http://localhost:3000` | | PostgreSQL | 5432 | Local database for development | | Valkey | 6379 | Local cache / event streams | | API | 8080 (internal) | Routed via gateway at `/api/` | diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 00000000..6a514403 --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,137 @@ +# ============================================================================= +# Gateway Caddy configuration +# ============================================================================= +# +# Single public entrypoint for the Paca stack: +# +# /api/* → REST API service (forwarded as-is, /api kept) +# /ws/* → Realtime service (Socket.IO, /ws stripped) +# /storage/* → MinIO (/storage stripped) +# /plugins/* → Plugin frontend assets (static files) +# /plugins-mcp/* → Plugin MCP bundles (static files) +# /* → Web application (SPA with client-side routing) +# +# This file is mounted at /etc/caddy/Caddyfile inside the gateway container. +# +# SITE_ADDRESS controls automatic HTTPS +# --------------------------------------- +# - A bare port (the default, ":80") serves plain HTTP with no TLS at all. +# Caddy can't safely provision a certificate for an unbounded catch-all +# address, so use this only when something else terminates TLS in front +# of this gateway. +# - Any concrete address — a domain name, an IP address, "localhost", a +# "*.localhost" name, etc. — gets HTTPS automatically: +# • A real domain name with DNS already pointed here (e.g. +# "paca.example.com") gets a trusted Let's Encrypt certificate, +# renewed automatically. Ports 80 and 443 must both be reachable +# from the internet for the ACME challenge to succeed. +# • Anything else (an IP address, "localhost", "*.localhost", ...) +# gets a certificate from Caddy's own local certificate authority +# instead. Traffic is still encrypted, but browsers will show a +# trust warning since that CA isn't publicly trusted. +# Either way, certificates persist in the caddy_data volume so they +# survive container restarts/recreations. +# install.sh enables HTTPS by default and prompts for this address. +# ============================================================================= + +{$SITE_ADDRESS::80} { + encode gzip + + log { + output stdout + } + + header { + X-Frame-Options "DENY" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + X-XSS-Protection "0" + -Server + } + + # Reject oversized request bodies at the gateway before they reach + # upstreams. Object storage uploads (handled below) are exempt. + @notStorage not path /storage/* + request_body @notStorage { + max_size 10MB + } + + # -- Object storage (MinIO) ------------------------------------------------ + # + # The Host header is rewritten to the internal MinIO address so that AWS + # Signature V4 validation succeeds: MinIO re-derives the HMAC using the + # Host it receives, which must match the host that was used when the + # presigned URL was generated (minio:9000). + # + # The API rewrites presigned URL hosts from "minio:9000" to + # STORAGE_PUBLIC_URL (e.g. "http://localhost/storage") before returning + # them to clients, so browsers always receive routable URLs. + handle_path /storage/* { + reverse_proxy minio:9000 { + header_up Host minio:9000 + } + } + + # -- Realtime service (Socket.IO) ------------------------------------------ + # + # WebSocket upgrade requests are detected and proxied automatically. + # + # Client-side connection: + # io("http://localhost", { path: "/ws/socket.io", withCredentials: true }) + handle_path /ws/* { + reverse_proxy realtime:3001 + } + + # -- Local plugin frontend assets ------------------------------------------- + # + # Serves statically-built micro-frontend bundles for locally installed + # plugins. Only plugins/local/frontend is mounted here (plugins/local/backend + # is mounted exclusively to the API container), so WASM binaries and SQL + # migrations are never reachable over HTTP. + # + # Set remoteEntryUrl in each plugin's plugin.json to: + # /plugins//assets/remoteEntry.js + handle_path /plugins/* { + root * /var/www/plugins + + @wasm path *.wasm + header @wasm Content-Type "application/wasm" + + # Fingerprinted assets can be cached immutably for a long time. + header Cache-Control "public, max-age=900, immutable" + file_server + } + + # -- MCP plugin bundles ------------------------------------------------------- + # + # Self-contained ESM bundles loaded at runtime by the MCP server. Kept on a + # separate path from frontend assets so WASM/CSS concerns from the block + # above don't bleed into MCP serving. + # + # Set remoteEntryUrl in each plugin's plugin.json mcp section to an + # absolute URL, for example: + # ${PUBLIC_URL}/plugins-mcp//mcp.js + handle_path /plugins-mcp/* { + root * /var/www/plugins-mcp + header Cache-Control "public, max-age=900, immutable" + file_server + } + + # -- API routes --------------------------------------------------------------- + # + # Forwards the /api prefix as-is to the upstream: + # /api/v1/users → /api/v1/users + # /api/healthz → /api/healthz + handle /api/* { + reverse_proxy api:8080 + } + + # -- Web application (SPA) ----------------------------------------------------- + # + # WebSocket upgrades are detected and proxied automatically, so Vite HMR + # and any future realtime traffic through this path work unmodified. + handle { + reverse_proxy web:3000 + } +} diff --git a/deploy/caddy/Caddyfile.dev b/deploy/caddy/Caddyfile.dev new file mode 100644 index 00000000..84cafabe --- /dev/null +++ b/deploy/caddy/Caddyfile.dev @@ -0,0 +1,80 @@ +# ============================================================================= +# Gateway Caddy configuration — DEVELOPMENT OVERRIDE +# ============================================================================= +# +# Identical to ../caddy/Caddyfile except the site address is hardcoded to +# plain HTTP on :80 — the dev stack never performs ACME/HTTPS issuance. +# Mounted by docker-compose.dev.yml; do not use in production. +# +# Caddy's reverse_proxy has no default read timeout (unlike nginx), so the +# long-lived Vite HMR / WebSocket connections that used to require a dev-only +# proxy_read_timeout override work unmodified — no other behavioral +# differences remain between this file and the production config. Keep the +# routing below in sync with ../caddy/Caddyfile. +# +# Single public entrypoint for the Paca stack: +# +# /api/* → REST API service (forwarded as-is, /api kept) +# /ws/* → Realtime service (Socket.IO, /ws stripped) +# /storage/* → MinIO (/storage stripped) +# /plugins/* → Plugin frontend assets (static files) +# /plugins-mcp/* → Plugin MCP bundles (static files) +# /* → Web application (SPA with client-side routing; +# Vite HMR WebSocket proxied automatically) +# ============================================================================= + +:80 { + encode gzip + + log { + output stdout + } + + header { + X-Frame-Options "DENY" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + X-XSS-Protection "0" + -Server + } + + @notStorage not path /storage/* + request_body @notStorage { + max_size 10MB + } + + handle_path /storage/* { + reverse_proxy minio:9000 { + header_up Host minio:9000 + } + } + + handle_path /ws/* { + reverse_proxy realtime:3001 + } + + handle_path /plugins/* { + root * /var/www/plugins + + @wasm path *.wasm + header @wasm Content-Type "application/wasm" + + header Cache-Control "public, max-age=900, immutable" + file_server + } + + handle_path /plugins-mcp/* { + root * /var/www/plugins-mcp + header Cache-Control "public, max-age=900, immutable" + file_server + } + + handle /api/* { + reverse_proxy api:8080 + } + + handle { + reverse_proxy web:3000 + } +} diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index b866b6c9..ea64f870 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -66,7 +66,7 @@ services: STORAGE_PROVIDER: minio STORAGE_ENDPOINT: minio:9000 # Public URL used to rewrite presigned URLs so browsers can reach MinIO - # through the nginx gateway instead of the internal Docker hostname. + # through the Caddy gateway instead of the internal Docker hostname. STORAGE_PUBLIC_URL: ${PUBLIC_HOST:-http://localhost}/storage STORAGE_REGION: us-east-1 STORAGE_BUCKET: paca @@ -135,7 +135,7 @@ services: PORT: "3001" NODE_ENV: development # Internal service-to-service URL — the realtime service calls the API - # directly, bypassing nginx, so no prefix stripping is needed. + # directly, bypassing Caddy, so no prefix stripping is needed. API_URL: http://api:8080 REDIS_URL: redis://valkey:6379/0 CORS_ORIGINS: ${PUBLIC_HOST:-http://localhost} @@ -150,12 +150,12 @@ services: condition: service_started gateway: - image: nginx:1.27-alpine + image: caddy:2-alpine restart: unless-stopped ports: - "3000:80" volumes: - - ./nginx/gateway.dev.conf:/etc/nginx/conf.d/default.conf:ro + - ./caddy/Caddyfile.dev:/etc/caddy/Caddyfile:ro # Frontend plugin store — built JS bundles, served at /plugins/. # Layout: /var/www/plugins//assets/remoteEntry.js - ../plugins/local/frontend:/var/www/plugins:ro @@ -209,7 +209,7 @@ services: INTERNAL_API_KEY: dev-internal-key-change-in-production API_BASE_URL: http://api:8080 # Gateway base URL — the MCP server uses this to resolve plugin MCP bundle - # URLs (e.g. /plugins-mcp//mcp.js). The gateway (nginx) serves these + # URLs (e.g. /plugins-mcp//mcp.js). The gateway (Caddy) serves these # files, not the API service. GATEWAY_BASE_URL: http://gateway # API key for the built-in Paca MCP server. Must match AGENT_API_KEY in diff --git a/deploy/docker-compose.e2e.yml b/deploy/docker-compose.e2e.yml index 06373f93..219d0343 100644 --- a/deploy/docker-compose.e2e.yml +++ b/deploy/docker-compose.e2e.yml @@ -135,12 +135,12 @@ services: retries: 20 gateway: - image: nginx:1.27-alpine + image: caddy:2-alpine restart: unless-stopped ports: - "80:80" volumes: - - ./nginx/gateway.conf:/etc/nginx/conf.d/default.conf:ro + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro # Frontend plugin store — built JS bundles, served at /plugins/. # Layout: /var/www/plugins//assets/remoteEntry.js - ../plugins/local/frontend:/var/www/plugins:ro @@ -188,7 +188,7 @@ services: INTERNAL_API_KEY: e2e-internal-api-key API_BASE_URL: http://api:8080 # Gateway base URL — the MCP server uses this to resolve plugin MCP bundle - # URLs (e.g. /plugins-mcp//mcp.js). The gateway (nginx) serves these + # URLs (e.g. /plugins-mcp//mcp.js). The gateway (Caddy) serves these # files, not the API service. GATEWAY_BASE_URL: http://gateway # Must match AGENT_API_KEY in the api service. diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index d0f4eaa9..ae7675ee 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -12,9 +12,9 @@ # bash install.sh # # ── Manual setup ────────────────────────────────────────────────────────────── -# 1. Download the nginx config alongside this file: -# mkdir -p nginx -# curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/gateway.conf -o nginx/gateway.conf +# 1. Download the Caddyfile alongside this file: +# mkdir -p caddy +# curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/Caddyfile -o caddy/Caddyfile # # 2. Create a .env file (see .env variables below). # @@ -154,7 +154,7 @@ services: - frontend_plugins:/plugins-frontend - mcp_plugins:/plugins-mcp - # ── Web (React SPA served via nginx) ──────────────────────────────────────── + # ── Web (React SPA served via Caddy) ──────────────────────────────────────── web: image: ${PACA_WEB_IMAGE:-pacaai/paca-web:latest} restart: unless-stopped @@ -178,17 +178,27 @@ services: api: condition: service_healthy - # ── Gateway (nginx reverse proxy) ─────────────────────────────────────────── + # ── Gateway (Caddy reverse proxy) ─────────────────────────────────────────── gateway: - image: nginx:1.27-alpine + image: caddy:2-alpine restart: unless-stopped ports: - "${GATEWAY_PORT:-80}:80" + - "${GATEWAY_HTTPS_PORT:-443}:443" + environment: + # A domain name (e.g. paca.example.com) makes Caddy request and renew + # a Let's Encrypt certificate automatically. Leave unset (or ":80") for + # IP-only / localhost deployments served over plain HTTP. + SITE_ADDRESS: ${SITE_ADDRESS:-:80} volumes: - # gateway.conf must exist alongside this docker-compose.yml. - - ./nginx/gateway.conf:/etc/nginx/conf.d/default.conf:ro + # Caddyfile must exist alongside this docker-compose.yml. + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro - frontend_plugins:/var/www/plugins:ro - mcp_plugins:/var/www/plugins-mcp:ro + # Persists issued TLS certificates/keys and Caddy's autosaved config + # across container restarts and recreations. + - caddy_data:/data + - caddy_config:/config depends_on: api: condition: service_started @@ -242,3 +252,5 @@ volumes: backend_plugins: frontend_plugins: mcp_plugins: + caddy_data: + caddy_config: diff --git a/deploy/nginx/gateway.conf b/deploy/nginx/gateway.conf deleted file mode 100644 index ddd27135..00000000 --- a/deploy/nginx/gateway.conf +++ /dev/null @@ -1,275 +0,0 @@ -# ============================================================================= -# Gateway nginx configuration -# ============================================================================= -# -# Single public entrypoint for the Paca stack: -# -# /api/* → REST API service (strips the /api prefix before forwarding) -# /* → Web application (SPA with client-side routing) -# -# This file is mounted as /etc/nginx/conf.d/default.conf inside the nginx -# container, so all top-level directives here run in the http {} context. -# -# Quick-reference for contributors -# --------------------------------- -# Rate limits → limit_req_zone lines + limit_req inside each location -# Upload size → client_max_body_size -# Timeouts → proxy_connect/send/read_timeout inside each location -# Security hdrs → add_header block inside the server {} block -# -# NOTE on proxy_set_header inheritance -# ------------------------------------- -# nginx does NOT merge proxy_set_header directives across contexts: if any -# proxy_set_header appears in a location block it replaces ALL inherited ones. -# To keep behaviour explicit and avoid surprises, headers are repeated in full -# inside every location block. -# ============================================================================= - -# -- Rate-limit zones --------------------------------------------------------- -# -# api — all API traffic: 100 req/s per IP, burst queue of 50. -# -# binary_remote_addr uses 4 B (IPv4) or 16 B (IPv6) per entry. -# 10 MB of shared memory tracks roughly 160 000 unique addresses. - -limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s; - -# -- WebSocket connection upgrade map ----------------------------------------- -# -# Required for correct HTTP → WebSocket proxying (RFC 6455 §4). -# Used by the web location block so Vite HMR and any future realtime service -# can upgrade connections through the gateway. - -map $http_upgrade $connection_upgrade { - default upgrade; - '' close; -} - - -# Resolve Docker service names for variable-based proxy_pass targets. -resolver 127.0.0.11 ipv6=off valid=30s; - -# -- Upstream pools ----------------------------------------------------------- -# -# keepalive N keeps N idle connections open to each upstream so that -# successive requests reuse TCP/TLS sessions instead of opening new ones. -# Pair with "proxy_set_header Connection \"\"" (empty) in each location to -# prevent the forwarded Connection header from closing those idle connections. -# -# Note: For optional services (web, minio), we use nginx variables to allow -# graceful startup when services are not running (e.g., minio=0 for S3). - -upstream api_backend { - server api:8080; - keepalive 32; -} - -upstream realtime_backend { - server realtime:3001; - keepalive 16; -} - -# ============================================================================= -server { - listen 80; - server_name _; - - # Hide the nginx version from error pages and the Server response header. - server_tokens off; - - # Reject oversized request bodies at the gateway before they reach upstreams. - client_max_body_size 10m; - - # -- Compression ---------------------------------------------------------- - # - # Compress text-based responses in transit. Level 5 is a good balance - # between CPU cost and compression ratio for typical API + SPA payloads. - # gzip_proxied any enables compression even when the upstream already set - # a Vary header (common with pre-compressed assets from the web container). - - gzip on; - gzip_proxied any; - gzip_comp_level 5; - gzip_min_length 256; - gzip_types - application/javascript - application/json - application/wasm - font/woff - font/woff2 - image/svg+xml - text/css - text/plain; - - # -- Security headers ----------------------------------------------------- - # - # Applied to every response regardless of which upstream served it. - # The `always` flag ensures headers are added even on error responses. - # - # X-XSS-Protection is intentionally 0: modern browsers enforce CSP instead - # and the legacy header can introduce vulnerabilities in older IE builds. - - add_header X-Frame-Options "DENY" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header X-XSS-Protection "0" always; - - # ========================================================================= - # Routing - # ========================================================================= - - # -- Object storage (MinIO) ----------------------------------------------- - # - # Proxies presigned upload and download requests to the internal MinIO - # container. The Host header is rewritten to the internal MinIO address - # so that AWS Signature V4 validation succeeds: MinIO re-derives the HMAC - # using the Host it receives, which must match the host that was used when - # the presigned URL was generated (minio:9000). - # - # The API rewrites presigned URL hosts from "minio:9000" to - # "STORAGE_PUBLIC_URL" (e.g. "http://localhost/storage") before returning - # them to clients, so browsers always receive routable URLs. - # - # Large uploads bypass the gateway body-size limit via client_max_body_size 0. - - location /storage/ { - set $minio_backend http://minio:9000; - # Strip the /storage/ prefix before forwarding to MinIO. - # proxy_pass with a variable does not perform URI substitution, so we - # rewrite explicitly and use proxy_pass without a trailing slash so that - # the rewritten URI (not just "/") is forwarded. - rewrite ^/storage/(.*)$ /$1 break; - proxy_pass $minio_backend; - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_set_header Host minio:9000; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - client_max_body_size 0; - proxy_request_buffering off; - proxy_connect_timeout 10s; - proxy_send_timeout 120s; - proxy_read_timeout 120s; - } - - # -- Realtime service (Socket.IO) ----------------------------------------- - # - # Proxies WebSocket upgrade requests for the Socket.IO server. - # The /ws/ prefix is stripped before forwarding so the realtime service - # sees the standard /socket.io/ path. - # - # Client-side connection: - # io("http://localhost", { path: "/ws/socket.io", withCredentials: true }) - - location /ws/ { - proxy_pass http://realtime_backend/; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_connect_timeout 10s; - proxy_send_timeout 60s; - # Long read timeout to keep WebSocket connections alive. - proxy_read_timeout 3600s; - } - - # -- API routes ----------------------------------------------------------- - # - # Forwards the /api prefix as-is to the upstream: - # /api/v1/users → /api/v1/users - # /api/healthz → /api/healthz - - location /api/ { - limit_req zone=api burst=50 nodelay; - limit_req_status 429; - - proxy_pass http://api_backend/api/; - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - proxy_read_timeout 30s; - } - - # -- Local plugin frontend assets ----------------------------------------- - # - # Serves statically-built micro-frontend bundles for locally installed - # plugins. Only the plugins/local/frontend directory is mounted here - # (plugins/local/backend is mounted exclusively to the API container), - # so WASM binaries and SQL migrations are never reachable over HTTP. - # - # URL layout: - # /plugins//assets/remoteEntry.js - # - # Set remoteEntryUrl in each plugin's plugin.json to: - # /plugins//assets/remoteEntry.js - - location /plugins/ { - alias /var/www/plugins/; - # Disable directory listings. - autoindex off; - # Allow WASM and JS content types used by module-federation bundles. - types { - application/javascript js; - application/wasm wasm; - text/css css; - text/html html; - image/svg+xml svg; - font/woff woff; - font/woff2 woff2; - } - - # Fingerprinted assets can be cached immutably for a long time. - add_header Cache-Control "public, max-age=900, immutable" always; - } - - # -- MCP plugin bundles --------------------------------------------------- - # - # Self-contained ESM bundles loaded at runtime by the MCP server. - # Kept on a separate path from frontend assets so WASM/CSS MIME types - # and module-federation concerns do not bleed into MCP serving. - # - # Set remoteEntryUrl in each plugin's plugin.json mcp section to an - # absolute URL, for example: - # ${PUBLIC_URL}/plugins-mcp//mcp.js - - location /plugins-mcp/ { - alias /var/www/plugins-mcp/; - autoindex off; - types { - application/javascript js; - } - add_header Cache-Control "public, max-age=900, immutable" always; - } - - # -- Web application (SPA) ------------------------------------------------ - # - # Upgrade + Connection headers are forwarded so WebSocket connections work - # for Vite HMR in the dev compose stack. - # proxy_read_timeout is set to 3600 s to keep WebSocket / SSE (HMR) - # connections alive; regular HTTP responses complete well before this. - - location / { - set $web_backend http://web:3000; - proxy_pass $web_backend; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - proxy_read_timeout 30s; - } -} diff --git a/deploy/nginx/gateway.dev.conf b/deploy/nginx/gateway.dev.conf deleted file mode 100644 index 117847e2..00000000 --- a/deploy/nginx/gateway.dev.conf +++ /dev/null @@ -1,282 +0,0 @@ -# ============================================================================= -# Gateway nginx configuration — DEVELOPMENT OVERRIDE -# ============================================================================= -# -# Identical to gateway.conf except proxy_read_timeout on the / location is -# 3600s (instead of 30s) to keep Vite HMR WebSocket connections alive. -# Mounted by docker-compose.dev.yml; do not use in production. -# -# Keep in sync with gateway.conf — the only intentional difference is the -# proxy_read_timeout value in the "Web application (SPA)" location block. -# -# Single public entrypoint for the Paca stack: -# -# /api/* → REST API service (strips the /api prefix before forwarding) -# /* → Web application (SPA with client-side routing) -# -# This file is mounted as /etc/nginx/conf.d/default.conf inside the nginx -# container, so all top-level directives here run in the http {} context. -# -# Quick-reference for contributors -# --------------------------------- -# Rate limits → limit_req_zone lines + limit_req inside each location -# Upload size → client_max_body_size -# Timeouts → proxy_connect/send/read_timeout inside each location -# Security hdrs → add_header block inside the server {} block -# -# NOTE on proxy_set_header inheritance -# ------------------------------------- -# nginx does NOT merge proxy_set_header directives across contexts: if any -# proxy_set_header appears in a location block it replaces ALL inherited ones. -# To keep behaviour explicit and avoid surprises, headers are repeated in full -# inside every location block. -# ============================================================================= - -# -- Rate-limit zones --------------------------------------------------------- -# -# api — all API traffic: 100 req/s per IP, burst queue of 50. -# -# binary_remote_addr uses 4 B (IPv4) or 16 B (IPv6) per entry. -# 10 MB of shared memory tracks roughly 160 000 unique addresses. - -limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s; - -# -- WebSocket connection upgrade map ----------------------------------------- -# -# Required for correct HTTP → WebSocket proxying (RFC 6455 §4). -# Used by the web location block so Vite HMR and any future realtime service -# can upgrade connections through the gateway. - -map $http_upgrade $connection_upgrade { - default upgrade; - '' close; -} - - -# Resolve Docker service names for variable-based proxy_pass targets. -resolver 127.0.0.11 ipv6=off valid=30s; - -# -- Upstream pools ----------------------------------------------------------- -# -# keepalive N keeps N idle connections open to each upstream so that -# successive requests reuse TCP/TLS sessions instead of opening new ones. -# Pair with "proxy_set_header Connection \"\"" (empty) in each location to -# prevent the forwarded Connection header from closing those idle connections. -# -# Note: For optional services (web, minio), we use nginx variables to allow -# graceful startup when services are not running (e.g., minio=0 for S3). - -upstream api_backend { - server api:8080; - keepalive 32; -} - -upstream realtime_backend { - server realtime:3001; - keepalive 16; -} - -# ============================================================================= -server { - listen 80; - server_name _; - - # Hide the nginx version from error pages and the Server response header. - server_tokens off; - - # Reject oversized request bodies at the gateway before they reach upstreams. - client_max_body_size 10m; - - # -- Compression ---------------------------------------------------------- - # - # Compress text-based responses in transit. Level 5 is a good balance - # between CPU cost and compression ratio for typical API + SPA payloads. - # gzip_proxied any enables compression even when the upstream already set - # a Vary header (common with pre-compressed assets from the web container). - - gzip on; - gzip_proxied any; - gzip_comp_level 5; - gzip_min_length 256; - gzip_types - application/javascript - application/json - application/wasm - font/woff - font/woff2 - image/svg+xml - text/css - text/plain; - - # -- Security headers ----------------------------------------------------- - # - # Applied to every response regardless of which upstream served it. - # The `always` flag ensures headers are added even on error responses. - # - # X-XSS-Protection is intentionally 0: modern browsers enforce CSP instead - # and the legacy header can introduce vulnerabilities in older IE builds. - - add_header X-Frame-Options "DENY" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header X-XSS-Protection "0" always; - - # ========================================================================= - # Routing - # ========================================================================= - - # -- Object storage (MinIO) ----------------------------------------------- - # - # Proxies presigned upload and download requests to the internal MinIO - # container. The Host header is rewritten to the internal MinIO address - # so that AWS Signature V4 validation succeeds: MinIO re-derives the HMAC - # using the Host it receives, which must match the host that was used when - # the presigned URL was generated (minio:9000). - # - # The API rewrites presigned URL hosts from "minio:9000" to - # "STORAGE_PUBLIC_URL" (e.g. "http://localhost/storage") before returning - # them to clients, so browsers always receive routable URLs. - # - # Large uploads bypass the gateway body-size limit via client_max_body_size 0. - - location /storage/ { - set $minio_backend http://minio:9000; - # Strip the /storage/ prefix before forwarding to MinIO. - # proxy_pass with a variable does not perform URI substitution, so we - # rewrite explicitly and use proxy_pass without a trailing slash so that - # the rewritten URI (not just "/") is forwarded. - rewrite ^/storage/(.*)$ /$1 break; - proxy_pass $minio_backend; - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_set_header Host minio:9000; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - client_max_body_size 0; - proxy_request_buffering off; - proxy_connect_timeout 10s; - proxy_send_timeout 120s; - proxy_read_timeout 120s; - } - - # -- Realtime service (Socket.IO) ----------------------------------------- - # - # Proxies WebSocket upgrade requests for the Socket.IO server. - # The /ws/ prefix is stripped before forwarding so the realtime service - # sees the standard /socket.io/ path. - # - # Client-side connection: - # io("http://localhost", { path: "/ws/socket.io", withCredentials: true }) - - location /ws/ { - proxy_pass http://realtime_backend/; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_connect_timeout 10s; - proxy_send_timeout 60s; - # Long read timeout to keep WebSocket connections alive. - proxy_read_timeout 3600s; - } - - # -- API routes ----------------------------------------------------------- - # - # Forwards the /api prefix as-is to the upstream: - # /api/v1/users → /api/v1/users - # /api/healthz → /api/healthz - - location /api/ { - limit_req zone=api burst=50 nodelay; - limit_req_status 429; - - proxy_pass http://api_backend/api/; - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - proxy_read_timeout 3600s; - } - - # -- Local plugin frontend assets ----------------------------------------- - # - # Serves statically-built micro-frontend bundles for locally installed - # plugins. Only the plugins/local/frontend directory is mounted here - # (plugins/local/backend is mounted exclusively to the API container), - # so WASM binaries and SQL migrations are never reachable over HTTP. - # - # URL layout: - # /plugins//assets/remoteEntry.js - # - # Set remoteEntryUrl in each plugin's plugin.json to: - # /plugins//assets/remoteEntry.js - - location /plugins/ { - alias /var/www/plugins/; - # Disable directory listings. - autoindex off; - # Allow WASM and JS content types used by module-federation bundles. - types { - application/javascript js; - application/wasm wasm; - text/css css; - text/html html; - image/svg+xml svg; - font/woff woff; - font/woff2 woff2; - } - - # Fingerprinted assets can be cached immutably for a long time. - add_header Cache-Control "public, max-age=900, immutable" always; - } - - # -- MCP plugin bundles --------------------------------------------------- - # - # Self-contained ESM bundles loaded at runtime by the MCP server. - # Kept on a separate path from frontend assets so WASM/CSS MIME types - # and module-federation concerns do not bleed into MCP serving. - # - # Set remoteEntryUrl in each plugin's plugin.json mcp section to an - # absolute URL, for example: - # ${PUBLIC_URL}/plugins-mcp//mcp.js - - location /plugins-mcp/ { - alias /var/www/plugins-mcp/; - autoindex off; - types { - application/javascript js; - } - add_header Cache-Control "public, max-age=900, immutable" always; - } - - # -- Web application (SPA) ------------------------------------------------ - # - # Upgrade + Connection headers are forwarded so WebSocket connections work - # for Vite HMR in the dev compose stack. - # proxy_read_timeout is set to 3600 s to keep WebSocket / SSE (HMR) - # connections alive; regular HTTP responses complete well before this. - - location / { - set $web_backend http://web:3000; - proxy_pass $web_backend; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - proxy_read_timeout 3600s; - } -} diff --git a/docs/architecture/repository-structure.md b/docs/architecture/repository-structure.md index 250c187a..972114c5 100644 --- a/docs/architecture/repository-structure.md +++ b/docs/architecture/repository-structure.md @@ -27,7 +27,7 @@ paca/ ├── docker-compose.dev.yml ├── docker-compose.prod.yml ├── docker-compose.e2e.yml - └── nginx/ # Gateway configuration mounted into nginx container + └── caddy/ # Gateway configuration mounted into the Caddy container ``` ## Why This Shape @@ -40,4 +40,4 @@ paca/ - `plugins/local` is the on-disk plugin store — WASM modules and frontend bundles land here after installation. - `scripts` holds the install script and plugin management helpers used by the CLI and the Marketplace UI. - `deploy` keeps all environment and infrastructure assets in one place. -- `deploy/nginx` holds gateway configuration that is mounted read-only into the nginx container at runtime, making it easy to review and modify without rebuilding images. +- `deploy/caddy` holds gateway configuration that is mounted read-only into the Caddy container at runtime, making it easy to review and modify without rebuilding images. diff --git a/docs/architecture/service-boundaries.md b/docs/architecture/service-boundaries.md index a83d6e1c..9b380215 100644 --- a/docs/architecture/service-boundaries.md +++ b/docs/architecture/service-boundaries.md @@ -32,7 +32,7 @@ Responsible for end-to-end validation of the full running stack from a real brow Concerns: -- Playwright test suites exercising cross-cutting flows spanning `apps/web`, `services/api`, and the nginx gateway; +- Playwright test suites exercising cross-cutting flows spanning `apps/web`, `services/api`, and the Caddy gateway; - test categories: auth flows, form validation, security (injection/XSS rejection), session management, and UX correctness; - Page Object Models and shared fixtures to keep test logic stable as the UI evolves; - global setup that logs in once and persists browser auth state. diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index b358b22a..bb188db4 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -17,11 +17,11 @@ Open `http://your-server-ip` when it finishes. Pulls pre-built images. No repository clone required. ```bash -# Download compose file and nginx config +# Download compose file and Caddyfile mkdir paca && cd paca curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/docker-compose.yml -o docker-compose.yml -mkdir -p nginx -curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/gateway.conf -o nginx/gateway.conf +mkdir -p caddy +curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/Caddyfile -o caddy/Caddyfile # Create an environment file cat > .env <<'EOF' @@ -40,6 +40,14 @@ docker compose --env-file .env up -d Open `http://localhost` — log in with `admin` and the password you set. +Want HTTPS? Set `SITE_ADDRESS` to a domain or IP address, with `PUBLIC_URL=https://…` +and `COOKIE_SECURE=true` to match. A real domain with DNS pointed here gets a trusted +Let's Encrypt certificate; an IP address or `localhost` gets one from Caddy's own local +CA instead (browsers will show a trust warning, but the connection is still encrypted). +The [install script](#option-1--install-script-recommended) enables this by default and +prompts for the address. See +[../../deploy/README.md](../../deploy/README.md#production-deployment) for details. + --- ## Option 3 — Local Development @@ -59,14 +67,18 @@ See [local-development.md](local-development.md) for details on the dev stack an ## Upgrading to a new version -Pull the latest images and restart the stack. Run these commands from the directory where your `docker-compose.yml` lives: +Run the upgrade script from the directory where your `docker-compose.yml` and `.env` +live. It refreshes `docker-compose.yml` and the Caddyfile (backing up the old ones +first), then pulls and restarts the stack: ```bash -docker compose pull -docker compose --env-file .env up -d +curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/upgrade.sh -o upgrade.sh +bash upgrade.sh ``` -Database migrations run automatically on API startup — no manual steps are required. +Database migrations run automatically on API startup — no manual steps are required. See +[../../deploy/README.md](../../deploy/README.md#upgrading-to-a-new-version) for pinning +a specific version, passing through `--scale` flags, or upgrading manually. --- diff --git a/docs/guides/local-development.md b/docs/guides/local-development.md index 52bba0a3..b77bc511 100644 --- a/docs/guides/local-development.md +++ b/docs/guides/local-development.md @@ -26,7 +26,7 @@ docker compose -f deploy/docker-compose.dev.yml down -v | Service | Technology | Port | Hot-reload | |---|---|---|---| -| Gateway (nginx) | nginx:1.27-alpine | **3000** (host) | — | +| Gateway (Caddy) | caddy:2-alpine | **3000** (host) | — | | `apps/web` | React + TanStack Start + shadcn/ui | 3000 (internal) | Vite HMR | | `services/api` | Go + Gin | 8080 (internal) | [air](https://github.com/air-verse/air) | | `services/realtime` | Node.js + Socket.IO | 3001 (internal) | `bun --watch` | @@ -36,7 +36,7 @@ docker compose -f deploy/docker-compose.dev.yml down -v | MinIO S3 API | minio/minio | 9000 | — | | MinIO Console | minio/minio | 9001 | http://localhost:9001 (user: `minioadmin`, pass: `minioadmin`) | -The nginx gateway (port 3000) routes `/api/v1/…` to the API, socket traffic to realtime, and `/storage/…` to MinIO. `apps/web` is served at the root. +The Caddy gateway (port 3000) routes `/api/v1/…` to the API, socket traffic to realtime, and `/storage/…` to MinIO. `apps/web` is served at the root. --- diff --git a/scripts/install.sh b/scripts/install.sh index 2bd91bca..55bb9e40 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -185,7 +185,7 @@ heading "Installation directory" PACA_DIR="${PACA_DIR:-./paca}" ask PACA_DIR "Where should Paca be installed?" "$PACA_DIR" -mkdir -p "${PACA_DIR}/nginx" +mkdir -p "${PACA_DIR}/caddy" cd "${PACA_DIR}" info "Working directory: $(pwd)" @@ -354,20 +354,54 @@ fi heading "Network" +echo " Caddy (the gateway) serves HTTPS by default: a trusted Let's Encrypt" +echo " certificate if you give it a domain name with DNS already pointed at" +echo " this server, or its own local certificate authority otherwise (an IP" +echo " address, \"localhost\", etc.) — traffic is still encrypted, but" +echo " browsers will show a trust warning until you have a real domain." +echo "" + +USE_HTTPS="yes" +yes_no USE_HTTPS "Serve over HTTPS?" "y" + GATEWAY_PORT="80" -ask GATEWAY_PORT "Gateway port (the port Paca will be accessible on)" "80" -# Derive a sensible default public URL from the port. -if [[ "$GATEWAY_PORT" == "80" ]]; then - _DEFAULT_PUBLIC_URL="http://localhost" -elif [[ "$GATEWAY_PORT" == "443" ]]; then - _DEFAULT_PUBLIC_URL="https://localhost" +if [[ "$USE_HTTPS" == "yes" ]]; then + # Best-effort default: this server's public IP, so hitting enter below + # still gives Caddy a concrete address to issue a certificate for. + # (Caddy can't provision HTTPS for a bare, hostname-less catch-all.) + _DETECTED_IP="" + if command -v curl &>/dev/null; then + _DETECTED_IP="$(curl -fsSL --max-time 3 https://api.ipify.org 2>/dev/null || true)" + fi + if [[ ! "$_DETECTED_IP" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then + _DETECTED_IP="" + fi + _DEFAULT_ADDRESS="${_DETECTED_IP:-localhost}" + + ADDRESS="" + ask ADDRESS "Domain name (recommended) or IP address Paca will be accessible at" "$_DEFAULT_ADDRESS" + + SITE_ADDRESS="$ADDRESS" + PUBLIC_URL="https://${ADDRESS}" + + info "Caddy will obtain a certificate for ${ADDRESS} on first start — a trusted" + info "Let's Encrypt certificate if DNS resolves here, otherwise a local one." + warn "Ports 80 and 443 must both be reachable from the internet for Let's Encrypt to succeed." else - _DEFAULT_PUBLIC_URL="http://localhost:${GATEWAY_PORT}" + SITE_ADDRESS=":80" + ask GATEWAY_PORT "Gateway port (the port Paca will be accessible on)" "80" + + # Derive a sensible default public URL from the port. + if [[ "$GATEWAY_PORT" == "80" ]]; then + _DEFAULT_PUBLIC_URL="http://localhost" + else + _DEFAULT_PUBLIC_URL="http://localhost:${GATEWAY_PORT}" + fi + + ask PUBLIC_URL "Public URL (full URL where Paca will be accessible, no trailing slash)" "$_DEFAULT_PUBLIC_URL" fi -PUBLIC_URL="" -ask PUBLIC_URL "Public URL (full URL where Paca will be accessible, no trailing slash)" "$_DEFAULT_PUBLIC_URL" PUBLIC_URL="${PUBLIC_URL%/}" # strip trailing slash # Set COOKIE_SECURE based on whether the URL uses HTTPS. @@ -390,7 +424,7 @@ heading "Web application" WEB_CHOICE="" ask_choice WEB_CHOICE "How do you want to serve the web app?" \ - "Bundled container (recommended – nginx serves the built React SPA)" \ + "Bundled container (recommended – Caddy serves the built React SPA)" \ "External hosting (S3, CloudFront, Vercel, etc. – only API services run here)" SCALE_WEB="" @@ -399,7 +433,7 @@ if [[ "$WEB_CHOICE" == *"External"* ]]; then echo "" warn "The web container will be skipped." warn "Build the SPA from source and deploy the dist/ folder to your CDN." - warn "Point your CDN's API proxy to: ${_DEFAULT_PUBLIC_URL:-http://localhost}/api" + warn "Point your CDN's API proxy to: ${PUBLIC_URL}/api" echo "" info "The gateway will still serve /api/, /ws/, and /storage/ routes." else @@ -439,11 +473,11 @@ else download "${RELEASE_BASE}/docker-compose.yml" docker-compose.yml fi -if [[ -f nginx/gateway.conf ]]; then - warn "nginx/gateway.conf already exists — skipping download." +if [[ -f caddy/Caddyfile ]]; then + warn "caddy/Caddyfile already exists — skipping download." else - info "Downloading nginx/gateway.conf..." - download "${RELEASE_BASE}/gateway.conf" nginx/gateway.conf + info "Downloading caddy/Caddyfile..." + download "${RELEASE_BASE}/Caddyfile" caddy/Caddyfile fi # ── Generate .env ───────────────────────────────────────────────────────────── @@ -484,6 +518,11 @@ PACA_AI_AGENT_IMAGE=pacaai/paca-ai-agent:${IMAGE_TAG} ENVIRONMENT=production GATEWAY_PORT=${GATEWAY_PORT} +GATEWAY_HTTPS_PORT=443 +# Caddy site address. A domain or IP gets HTTPS automatically (a trusted +# Let's Encrypt certificate for a real domain, Caddy's own local certificate +# authority otherwise). Set to ":80" to disable HTTPS and serve plain HTTP. +SITE_ADDRESS=${SITE_ADDRESS} # ── Public URL ──────────────────────────────────────────────────────────────── PUBLIC_URL=${PUBLIC_URL} @@ -548,6 +587,7 @@ echo "" echo -e " ${BOLD}Directory ${RESET}$(pwd)" echo -e " ${BOLD}Version ${RESET}${PACA_VERSION}" echo -e " ${BOLD}Public URL ${RESET}${PUBLIC_URL}" +echo -e " ${BOLD}HTTPS ${RESET}$( [[ "$USE_HTTPS" == "yes" ]] && echo "Enabled (${SITE_ADDRESS})" || echo "Disabled (plain HTTP)" )" echo -e " ${BOLD}Database ${RESET}$( [[ -n "$SCALE_POSTGRES" ]] && echo "External PostgreSQL" || echo "Bundled PostgreSQL container" )" echo -e " ${BOLD}Storage ${RESET}$( [[ "$STORAGE_PROVIDER" == "s3" ]] && echo "AWS S3 (${STORAGE_BUCKET})" || echo "Self-hosted MinIO" )" echo -e " ${BOLD}Web app ${RESET}$( [[ -n "$SCALE_WEB" ]] && echo "External / CDN (container skipped)" || echo "Bundled container" )" diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh new file mode 100755 index 00000000..b0302c47 --- /dev/null +++ b/scripts/upgrade.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Paca – upgrade script +# +# Updates an existing Paca installation (created by install.sh, or set up +# manually per deploy/README.md) to a new release: refreshes +# docker-compose.yml and the Caddyfile, re-pins image versions in .env when a +# specific version is requested, then pulls and restarts the stack. +# +# Run this from the directory that holds your docker-compose.yml and .env +# (the directory install.sh created, or wherever you set things up manually). +# +# ── Recommended (interactive) ──────────────────────────────────────────────── +# cd /path/to/your/paca/install +# curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/upgrade.sh -o upgrade.sh +# bash upgrade.sh +# +# ── One-liner (non-interactive, upgrades to latest) ─────────────────────────── +# bash <(curl -fsSL https://github.com/Paca-AI/paca/releases/latest/download/upgrade.sh) +# +# ── Environment variable overrides ─────────────────────────────────────────── +# PACA_DIR Installation directory to upgrade (default: .) +# PACA_VERSION Release tag to upgrade to (default: latest) +# PACA_YES Skip prompts, use defaults (set to 1) +# +# Extra arguments are passed through to the final `docker compose up -d`, +# e.g. to keep the same service scaling you used originally: +# bash upgrade.sh --scale web=0 --scale minio=0 + +set -euo pipefail + +# ── Colours ─────────────────────────────────────────────────────────────────── + +BOLD='\033[1m'; DIM='\033[2m' +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m' +RESET='\033[0m' + +info() { echo -e "${GREEN}✔${RESET} $*"; } +warn() { echo -e "${YELLOW}!${RESET} $*"; } +error() { echo -e "${RED}✖${RESET} $*" >&2; } +die() { error "$*"; exit 1; } +heading() { echo -e "\n${BOLD}${CYAN}── $* ${RESET}${DIM}$(printf '─%.0s' {1..40})${RESET}"; } +bold() { echo -e "${BOLD}$*${RESET}"; } + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +# ask VAR "Question" "default" +# Reads from /dev/tty when stdin is a pipe (curl | bash). +ask() { + local _var="$1" + local question="$2" + local default="${3:-}" + local prompt + + if [[ -n "$default" ]]; then + prompt="${BOLD}→${RESET} ${question} ${DIM}[${default}]${RESET}: " + else + prompt="${BOLD}→${RESET} ${question}: " + fi + + local _input="" + if [[ "${PACA_YES:-0}" == "1" ]]; then + printf -v "$_var" %s "${default}" + return + fi + if [[ -t 0 ]]; then + read -r -p "$(echo -e "$prompt")" _input + elif [[ -e /dev/tty ]]; then + read -r -p "$(echo -e "$prompt")" _input /dev/null; then + curl -fsSL --retry 3 "$url" -o "$dest" + elif command -v wget &>/dev/null; then + wget -q --tries=3 -O "$dest" "$url" + else + die "Neither curl nor wget found. Install one and retry." + fi +} + +# set_env_var FILE VAR VALUE +# Replaces an existing "VAR=..." line in FILE, or appends it if absent. +# Goes through a temp file rather than `sed -i` to avoid GNU/BSD differences. +set_env_var() { + local file="$1" var="$2" value="$3" tmp + tmp="$(mktemp)" + if grep -q "^${var}=" "$file" 2>/dev/null; then + awk -v var="$var" -v val="$value" -F= ' + $1 == var { print var "=" val; next } + { print } + ' "$file" > "$tmp" + else + cp "$file" "$tmp" + printf '%s=%s\n' "$var" "$value" >> "$tmp" + fi + mv "$tmp" "$file" +} + +# ── Version / URL resolution ────────────────────────────────────────────────── + +PACA_VERSION="${PACA_VERSION:-latest}" + +if [[ "$PACA_VERSION" == "latest" ]]; then + RELEASE_BASE="https://github.com/Paca-AI/paca/releases/latest/download" +else + RELEASE_BASE="https://github.com/Paca-AI/paca/releases/download/${PACA_VERSION}" +fi + +# Strip leading 'v' for Docker image tags (v1.2.3 → 1.2.3). +IMAGE_TAG="${PACA_VERSION#v}" + +# ── Preflight ───────────────────────────────────────────────────────────────── + +echo "" +bold "╔══════════════════════════════════════════════════════════╗" +bold "║ Paca – upgrade an existing installation ║" +bold "╚══════════════════════════════════════════════════════════╝" +echo "" + +if ! command -v docker &>/dev/null; then + die "Docker is not installed. Get it at https://docs.docker.com/get-docker/" +fi +if ! docker info &>/dev/null 2>&1; then + die "Docker daemon is not running. Start Docker Desktop (or the daemon) and retry." +fi + +COMPOSE_CMD="" +if docker compose version &>/dev/null 2>&1; then + COMPOSE_CMD="docker compose" +elif command -v docker-compose &>/dev/null; then + COMPOSE_CMD="docker-compose" +else + die "Docker Compose not found. Install it from https://docs.docker.com/compose/install/" +fi + +info "Docker OK (compose: $COMPOSE_CMD)" + +PACA_DIR="${PACA_DIR:-.}" +cd "${PACA_DIR}" +info "Installation directory: $(pwd)" + +if [[ ! -f docker-compose.yml || ! -f .env ]]; then + die "No existing Paca installation found here (missing docker-compose.yml and/or .env). Use install.sh for a fresh install, or set PACA_DIR to point at your install directory." +fi + +# ── Current vs target version ───────────────────────────────────────────────── + +heading "Version" + +CURRENT_TAG="unknown" +if grep -q "^PACA_API_IMAGE=" .env; then + CURRENT_TAG="$(grep "^PACA_API_IMAGE=" .env | head -1 | sed -e 's/^PACA_API_IMAGE=//' -e 's/.*://')" +fi + +info "Current version: ${CURRENT_TAG}" +info "Target version: ${IMAGE_TAG}" + +if [[ -f nginx/gateway.conf ]]; then + heading "Gateway migration" + warn "This installation predates the nginx → Caddy gateway migration." + info "caddy/Caddyfile will be downloaded; nginx/ is no longer referenced by docker-compose.yml." + info "Once you've confirmed the upgraded stack works, the nginx/ directory can be removed." +fi + +PROCEED="yes" +yes_no PROCEED "Proceed with upgrade?" "y" +if [[ "$PROCEED" != "yes" ]]; then + warn "Upgrade cancelled." + exit 0 +fi + +mkdir -p caddy + +# ── Backup and refresh infrastructure files ─────────────────────────────────── + +heading "Backing up and refreshing infrastructure files" + +TS="$(date +%s)" + +cp docker-compose.yml "docker-compose.yml.bak.${TS}" +info "Backed up docker-compose.yml → docker-compose.yml.bak.${TS}" +download "${RELEASE_BASE}/docker-compose.yml" docker-compose.yml +info "Downloaded the latest docker-compose.yml." + +if [[ -f caddy/Caddyfile ]]; then + cp caddy/Caddyfile "caddy/Caddyfile.bak.${TS}" + info "Backed up caddy/Caddyfile → caddy/Caddyfile.bak.${TS}" +fi +download "${RELEASE_BASE}/Caddyfile" caddy/Caddyfile +info "Downloaded the latest caddy/Caddyfile." + +# Only re-pin image tags in .env when a specific version was requested. +# Installs left on the default ":latest" floating tag are already upgraded +# by the pull below — rewriting them here would silently switch a +# deliberately-pinned install onto floating tags, or vice versa. +if [[ "$PACA_VERSION" != "latest" ]]; then + cp .env ".env.bak.${TS}" + info "Backed up .env → .env.bak.${TS}" + + for var in PACA_API_IMAGE PACA_WEB_IMAGE PACA_REALTIME_IMAGE PACA_AI_AGENT_IMAGE; do + image_name="$(echo "$var" | sed -e 's/^PACA_//' -e 's/_IMAGE$//' | tr '[:upper:]' '[:lower:]' | sed 's/_/-/g')" + set_env_var .env "$var" "pacaai/paca-${image_name}:${IMAGE_TAG}" + done + info "Pinned image versions in .env to ${IMAGE_TAG}." +else + info "Using floating :latest images — no .env changes needed." +fi + +# ── Pull and restart ────────────────────────────────────────────────────────── + +heading "Pulling images and restarting" + +# shellcheck disable=SC2086 +$COMPOSE_CMD --env-file .env pull +# shellcheck disable=SC2086 +$COMPOSE_CMD --env-file .env up -d --remove-orphans "$@" + +# ── Done ────────────────────────────────────────────────────────────────────── + +echo "" +bold "╔══════════════════════════════════════════════════════════╗" +bold "║ Paca has been upgraded! ║" +bold "╚══════════════════════════════════════════════════════════╝" +echo "" +info "Version: ${IMAGE_TAG}" +echo "" +echo -e "${DIM}Database migrations run automatically on API startup.${RESET}" +echo "" +echo -e " ${BOLD}Check status:${RESET} ${COMPOSE_CMD} --env-file .env ps" +echo -e " ${BOLD}View logs:${RESET} ${COMPOSE_CMD} --env-file .env logs -f" +echo "" diff --git a/services/ai-agent/src/config.py b/services/ai-agent/src/config.py index a0fc0328..3fc13cdb 100644 --- a/services/ai-agent/src/config.py +++ b/services/ai-agent/src/config.py @@ -19,7 +19,7 @@ class Settings(BaseSettings): internal_api_key: str = Field(min_length=1) api_base_url: str = "http://api:8080" # Gateway base URL — used by the MCP server to resolve plugin MCP bundle URLs. - # The gateway (nginx) serves /plugins-mcp/, not the API service, so this must + # The gateway (Caddy) serves /plugins-mcp/, not the API service, so this must # point to the gateway's internal address. gateway_base_url: str = "http://gateway" diff --git a/services/realtime/.env.example b/services/realtime/.env.example index 3b052926..65633b92 100644 --- a/services/realtime/.env.example +++ b/services/realtime/.env.example @@ -6,7 +6,7 @@ PORT=3001 # Internal URL of services/api used for token verification and permission lookups. -# When running outside Docker, point this at the API directly (not through nginx). +# When running outside Docker, point this at the API directly (not through Caddy). API_URL=http://localhost:8080 # Valkey / Redis connection URL. Must point to the same instance the API uses. diff --git a/services/realtime/README.md b/services/realtime/README.md index 7ff4e1da..086ae801 100644 --- a/services/realtime/README.md +++ b/services/realtime/README.md @@ -43,7 +43,7 @@ bun run dev docker compose -f deploy/docker-compose.dev.yml up -d ``` -The service is reachable through the nginx gateway at `http://localhost/ws/`. +The service is reachable through the Caddy gateway at `http://localhost/ws/`. ## Environment variables @@ -62,7 +62,7 @@ The service is reachable through the nginx gateway at `http://localhost/ws/`. import { io } from "socket.io-client"; const socket = io("http://localhost", { - path: "/ws/socket.io", // nginx strips the /ws prefix + path: "/ws/socket.io", // Caddy strips the /ws prefix withCredentials: true, // sends the access_token cookie automatically }); diff --git a/services/realtime/src/server.ts b/services/realtime/src/server.ts index 6b9ab473..a0dbdba2 100644 --- a/services/realtime/src/server.ts +++ b/services/realtime/src/server.ts @@ -33,7 +33,7 @@ // Client-side example (using socket.io-client): // // const socket = io("http://localhost", { -// path: "/ws/socket.io", // nginx strips the /ws prefix +// path: "/ws/socket.io", // Caddy strips the /ws prefix // withCredentials: true, // send access_token cookie automatically // }); // socket.emit("join", { projectId: "" }); From 5bca8e066938bb1f5b4abb32525d40d15dc4105c Mon Sep 17 00:00:00 2001 From: pikann22 Date: Wed, 24 Jun 2026 09:02:54 +0000 Subject: [PATCH 2/3] feat: enhance upgrade script to backfill .env variables for Caddy migration --- scripts/upgrade.sh | 58 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index b0302c47..e16b5333 100755 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -4,7 +4,8 @@ # Updates an existing Paca installation (created by install.sh, or set up # manually per deploy/README.md) to a new release: refreshes # docker-compose.yml and the Caddyfile, re-pins image versions in .env when a -# specific version is requested, then pulls and restarts the stack. +# specific version is requested, backfills any .env variables introduced +# since the install was created, then pulls and restarts the stack. # # Run this from the directory that holds your docker-compose.yml and .env # (the directory install.sh created, or wherever you set things up manually). @@ -118,6 +119,16 @@ set_env_var() { mv "$tmp" "$file" } +# has_env_var FILE VAR +has_env_var() { + grep -q "^${2}=" "$1" 2>/dev/null +} + +# get_env_var FILE VAR +get_env_var() { + grep "^${2}=" "$1" 2>/dev/null | head -1 | cut -d= -f2- +} + # ── Version / URL resolution ────────────────────────────────────────────────── PACA_VERSION="${PACA_VERSION:-latest}" @@ -181,6 +192,7 @@ if [[ -f nginx/gateway.conf ]]; then heading "Gateway migration" warn "This installation predates the nginx → Caddy gateway migration." info "caddy/Caddyfile will be downloaded; nginx/ is no longer referenced by docker-compose.yml." + info "SITE_ADDRESS and GATEWAY_HTTPS_PORT will be added to .env so Caddy can serve HTTPS automatically." info "Once you've confirmed the upgraded stack works, the nginx/ directory can be removed." fi @@ -211,21 +223,57 @@ fi download "${RELEASE_BASE}/Caddyfile" caddy/Caddyfile info "Downloaded the latest caddy/Caddyfile." +ENV_BACKED_UP=0 +backup_env_once() { + if [[ "$ENV_BACKED_UP" == "0" ]]; then + cp .env ".env.bak.${TS}" + info "Backed up .env → .env.bak.${TS}" + ENV_BACKED_UP=1 + fi +} + # Only re-pin image tags in .env when a specific version was requested. # Installs left on the default ":latest" floating tag are already upgraded # by the pull below — rewriting them here would silently switch a # deliberately-pinned install onto floating tags, or vice versa. if [[ "$PACA_VERSION" != "latest" ]]; then - cp .env ".env.bak.${TS}" - info "Backed up .env → .env.bak.${TS}" - + backup_env_once for var in PACA_API_IMAGE PACA_WEB_IMAGE PACA_REALTIME_IMAGE PACA_AI_AGENT_IMAGE; do image_name="$(echo "$var" | sed -e 's/^PACA_//' -e 's/_IMAGE$//' | tr '[:upper:]' '[:lower:]' | sed 's/_/-/g')" set_env_var .env "$var" "pacaai/paca-${image_name}:${IMAGE_TAG}" done info "Pinned image versions in .env to ${IMAGE_TAG}." else - info "Using floating :latest images — no .env changes needed." + info "Using floating :latest images — no image version changes needed." +fi + +# Backfill variables introduced by the nginx → Caddy gateway migration. +# Installations from before that release have neither in .env. SITE_ADDRESS +# defaults to the hostname already in PUBLIC_URL so Caddy requests a +# certificate for the address Paca is actually reachable at, rather than +# silently leaving the upgraded gateway on plain HTTP. +GATEWAY_VARS_ADDED=0 +if ! has_env_var .env SITE_ADDRESS; then + backup_env_once + _PUBLIC_URL="$(get_env_var .env PUBLIC_URL)" + _SITE_ADDRESS="${_PUBLIC_URL#http://}" + _SITE_ADDRESS="${_SITE_ADDRESS#https://}" + _SITE_ADDRESS="${_SITE_ADDRESS%%/*}" + _SITE_ADDRESS="${_SITE_ADDRESS%%:*}" + _SITE_ADDRESS="${_SITE_ADDRESS:-localhost}" + set_env_var .env SITE_ADDRESS "$_SITE_ADDRESS" + info "Added SITE_ADDRESS=${_SITE_ADDRESS} to .env (derived from your existing PUBLIC_URL)." + GATEWAY_VARS_ADDED=1 +fi +if ! has_env_var .env GATEWAY_HTTPS_PORT; then + backup_env_once + set_env_var .env GATEWAY_HTTPS_PORT "443" + info "Added GATEWAY_HTTPS_PORT=443 to .env." + GATEWAY_VARS_ADDED=1 +fi +if [[ "$GATEWAY_VARS_ADDED" == "1" ]]; then + warn "Ports 80 and 443 must both be reachable from the internet for Let's Encrypt to succeed." + info "Already behind another TLS terminator (a load balancer, Cloudflare, etc.)? Set SITE_ADDRESS=:80 in .env to keep this gateway on plain HTTP." fi # ── Pull and restart ────────────────────────────────────────────────────────── From 8c6cf489edcf72f26cf54c6ebf8e2b2c5ebb2aa6 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Wed, 24 Jun 2026 09:11:19 +0000 Subject: [PATCH 3/3] feat: update install script to configure HTTPS based on user input for domain or IP --- scripts/install.sh | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 55bb9e40..24670cb8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -357,31 +357,25 @@ heading "Network" echo " Caddy (the gateway) serves HTTPS by default: a trusted Let's Encrypt" echo " certificate if you give it a domain name with DNS already pointed at" echo " this server, or its own local certificate authority otherwise (an IP" -echo " address, \"localhost\", etc.) — traffic is still encrypted, but" -echo " browsers will show a trust warning until you have a real domain." +echo " address, etc.) — traffic is still encrypted, but browsers will show a" +echo " trust warning until you have a real domain. \"localhost\" is served" +echo " over plain HTTP instead, since it never needs (or can get) a certificate." echo "" -USE_HTTPS="yes" -yes_no USE_HTTPS "Serve over HTTPS?" "y" +ADDRESS="" +ask ADDRESS "Domain name (recommended) or IP address Paca will be accessible at" "localhost" GATEWAY_PORT="80" -if [[ "$USE_HTTPS" == "yes" ]]; then - # Best-effort default: this server's public IP, so hitting enter below - # still gives Caddy a concrete address to issue a certificate for. - # (Caddy can't provision HTTPS for a bare, hostname-less catch-all.) - _DETECTED_IP="" - if command -v curl &>/dev/null; then - _DETECTED_IP="$(curl -fsSL --max-time 3 https://api.ipify.org 2>/dev/null || true)" - fi - if [[ ! "$_DETECTED_IP" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then - _DETECTED_IP="" - fi - _DEFAULT_ADDRESS="${_DETECTED_IP:-localhost}" - - ADDRESS="" - ask ADDRESS "Domain name (recommended) or IP address Paca will be accessible at" "$_DEFAULT_ADDRESS" +if [[ "$ADDRESS" == "localhost" ]]; then + USE_HTTPS="no" + info "Using localhost — serving over plain HTTP." +else + USE_HTTPS="yes" + yes_no USE_HTTPS "Serve over HTTPS?" "y" +fi +if [[ "$USE_HTTPS" == "yes" ]]; then SITE_ADDRESS="$ADDRESS" PUBLIC_URL="https://${ADDRESS}" @@ -394,9 +388,9 @@ else # Derive a sensible default public URL from the port. if [[ "$GATEWAY_PORT" == "80" ]]; then - _DEFAULT_PUBLIC_URL="http://localhost" + _DEFAULT_PUBLIC_URL="http://${ADDRESS}" else - _DEFAULT_PUBLIC_URL="http://localhost:${GATEWAY_PORT}" + _DEFAULT_PUBLIC_URL="http://${ADDRESS}:${GATEWAY_PORT}" fi ask PUBLIC_URL "Public URL (full URL where Paca will be accessible, no trailing slash)" "$_DEFAULT_PUBLIC_URL"