# dotnews-paas Minimal self-hosted deployment platform (a very basic Coolify/Vercel), Docker-free: ``` Internet → OPNsense (NAT 80/443 → VM) → Caddy :443 auto-TLS, redirects, serves static assets directly ├─ cached apps → Varnish :6081 (page cache, purge-on-publish) → app port ├─ other apps → app port directly └─ deploy panel → PM2 app (deploy.younex.de) PM2 · one Node app per port (3001+) · deploys via panel webhooks, Jenkins or GitLab CI ``` Everything is conventions + one bash CLI (`paas`) + a small web panel. The CLI is the only thing that writes server state; the panel and all CI paths call it. Two app runtimes, chosen per project at creation (`RUNTIME=` in the registry): | | `node` (default) | `laravel` (servers bootstrapped with `WITH_LARAVEL=1`) | |---|---|---| | Build | `npm ci && npm run build` → `.output` | `composer install --no-dev … && npm run build` → whole checkout | | Runtime | PM2 process on `127.0.0.1:PORT` | PHP-FPM pool `paas-` (unix socket), Caddy `php_fastcgi` | | Edge cache | optional Varnish | not available (VCL strips cookies → sessions/CSRF) | | Health-gate | release booted on `PORT+1000` | release served via a `staging` symlink on `PORT+1000` through the real pool | | Extras | — | migrations on deploy, scheduler timer, PM2 queue worker, `paas db` MariaDB | ## Install (fresh Debian 12 VM) ```bash # 1. from the workstation: create the VM on Proxmox PROXMOX_HOST=root@ TEMPLATE_VMID=9000 NEW_VMID=210 \ VM_IP_CIDR=192.168.12.80/24 VM_GW=192.168.12.1 \ SSH_PUBKEY_FILE=~/.ssh/id_ed25519.pub infra/provision-vm.sh # 2. copy the repo over and bootstrap rsync -a ./ debian@192.168.12.80:dotnews-paas/ ssh debian@192.168.12.80 "cd dotnews-paas && sudo PANEL_DOMAIN=deploy.younex.de PANEL_ADMIN_PASSWORD='...' ./bootstrap.sh" # Laravel-capable server (adds PHP-FPM 8.3/8.4, composer, MariaDB, timers): # sudo WITH_LARAVEL=1 PANEL_DOMAIN=deploy-php.younex.de PANEL_ADMIN_PASSWORD='...' ./bootstrap.sh # 3. OPNsense: NAT 80/443 from a free virtual IP to the VM (infra/opnsense-notes.md) # 4. DNS: point the panel + app domains at that virtual IP ``` ## New app in 3 steps 1. **Create** — in the panel (`https://deploy.younex.de` → New project) or CLI: `paas create myapp --domains www.example.com --cache` 2. **DNS** — point the domain at the virtual IP. Caddy fetches certificates automatically. 3. **Connect the repo** — paste the project's webhook URL (shown on the project page) into GitHub/GitLab/Gitea webhook settings. Every push to the configured branch builds and deploys. Alternatively use `ci/deploy.yml` (GitLab CI) or `ci/Jenkinsfile.template` (Jenkins) — both just call `paas deploy` over SSH, and all deployments show up in the panel dashboard. The platform itself (this repo) deploys to production the same way: a push to `main` runs the Jenkins job `paas-platform`, see `docs/ci-platform-deploy.md`. ## Daily operations | Action | Panel | CLI | |---|---|---| | Deploy | "Deploy now" button / git push | `paas deploy ` | | Rollback | "Rollback" button | `paas rollback ` | | Logs | project → Logs | `paas logs ` | | Purge cache | — | `paas ban ` | | Warm cache (pre-render WARM_PATHS) | — | `paas warm ` | | Domains | — | `paas domains --add/--remove ` | | Status | dashboard | `paas list` | | Restart / apply env | "Restart app" | `paas restart ` | | Database | project → Database card | `paas db create\|show\|drop\|backup\|shell ` | | PHP version | project → Runtime | `paas php --set 8.4` | | Laravel switches | project → Laravel card | `paas set --migrate\|--queue\|--scheduler on\|off` | | Repair configs | — | `paas regen` | ## How deploys work (safety model) `paas deploy` copies the build into a new release dir, boots it on a **test port** and only if it answers its health check does the `current` symlink flip and PM2 restart — a failing build or broken release can never take the running version down. After activation the previous release is kept (last 5) for one-command rollback. For cached apps the deploy ends with a Varnish purge. ## Laravel apps (`--runtime laravel`) Only on servers bootstrapped with `WITH_LARAVEL=1` (`RUNTIMES=node,laravel` in `/srv/paas/paas.conf` is what the panel and CLI gate on). Everything else — domains, TLS, geo/domain rules, basic-auth, traffic metrics, Checkmk, ZIP deploys, webhooks — works exactly as for Node apps. - **Serving**: Caddy `php_fastcgi` → per-app PHP-FPM pool (`/srv/paas/php//paas-.conf`, user `deploy`, socket `/run/php/paas-.sock`), `public/` served by Caddy's `file_server`, `/build/*` immutable. The loopback `http://127.0.0.1:PORT` listener is what health probes/monitoring hit (same contract as PM2 apps). - **Releases**: `paas deploy ` copies the *built* checkout (needs `vendor/` + `public/`), links `storage/` and `.env` from `shared/`, runs `config|route|view|event:cache` (fatal = release can't boot), health-gates it through the real pool (`staging` symlink, port `PORT+1000`), runs `migrate --force` (toggle `MIGRATE`), flips `current`. No PHP-FPM reload per deploy: Caddy's `resolve_root_symlink` makes opcache key on the real release path. Rollback = symlink flip (migrations are not reversed). - **Env**: `shared/.env` is the Laravel `.env` (symlinked into every release). `paas create` seeds `APP_KEY`, `APP_ENV=production`, `APP_DEBUG=false`, `APP_URL`, `LOG_CHANNEL=daily`. Because of `config:cache`, env edits need `paas restart ` (rebuilds the cache, reloads fpm) or a deploy. - **Health**: default `HEALTH_PATH=/up` (Laravel 11+). Older apps: `--health /`. The gate runs *before* migrations — keep it independent of new schema. - **Scheduler**: `paas set --scheduler on` → the `paas-laravel-scheduler` timer runs `schedule:run` every minute (log `storage/logs/scheduler.log`). - **Queue**: `paas set --queue on` → PM2 process `-queue` running `QUEUE_CMD` (`--queue-cmd` to change); deploys send `queue:restart`. - **PHP versions**: `paas php --set 8.4` moves the pool (socket path stays). Versions installed: `PHP_VERSIONS` at bootstrap (`/srv/paas/php/bin//php` shims). - **Logs**: `paas logs ` tails `storage/logs/{laravel*,php-fpm,scheduler}.log`. ## Databases (`paas db`, MariaDB — any runtime) `WITH_LARAVEL=1` installs MariaDB bound to `127.0.0.1` and a `paas_admin` user whose credentials live in `/srv/paas/db-admin.cnf` (0600 `deploy`; no sudo involved). Per app: `paas db create ` makes database + user `` (utf8mb4), writes `DB_CONNECTION/DB_HOST/DB_PORT/ DB_DATABASE/DB_USERNAME/DB_PASSWORD` (Node apps also `DATABASE_URL`) into `shared/.env` — existing differing keys are kept unless `--force-env` — and is idempotent (re-run = show). `show`, `shell`, `backup` (`mariadb-dump` → `/srv/paas/backups/db/-.sql.gz`, last 7 kept; nightly timer runs `backup --all`), `drop --yes` (backs up first). `paas remove --purge` drops the database after a final dump. From a workstation: `ssh -L 3306:127.0.0.1:3306 deploy@` then connect to `localhost:3306`. ## Caching (opt-in per app, `--cache`) Varnish sits between Caddy and the app. Policy (templates/base.vcl): - TTLs come from the app: `Cache-Control: s-maxage=…, stale-while-revalidate=…` (no header → 120 s micro-cache). Browsers always get `no-cache` — the edge absorbs revalidation. - Only GET/HEAD are cached; POST always passes; other methods → 405. - 5xx never enters the cache — stale content is served instead (up to 72 h, 168 h if the backend is down entirely). - Tracking params (`utm_*`, `gclid`, `fbclid`, …) are stripped from the cache key; remaining query params are sorted. - Purge: `paas ban `, or from an app on the box: `curl -X BAN -H "X-Ban-Host: www.example.com" http://127.0.0.1:6081/` (localhost only). Wire a CMS publish-webhook to an app route that calls this and long TTLs stay instantly fresh. - Deploy/rollback bans send `X-Ban-Path: ^/(?!api/img)` — everything for the host is purged EXCEPT `/api/img` objects (content-hashed immutable images; re-encoding them after every deploy was pure waste). For apps without an `/api/img` route the regex is equivalent to a full host ban. - Per-app never-cache routes: `--pass-paths ""` at create time. - Warming: deploys/rollbacks end with `paas warm` (fetches `WARM_PATHS`, default `/`, through Varnish so the cache is HIT-ready). With `WARM_SITEMAP=/news-sitemap.xml` (`--warm-sitemap`, set automatically by the panel's multi-label preset) the warm additionally fetches that sitemap and pre-renders up to `WARM_SITEMAP_LIMIT` (default 50) of its `` URLs on the primary domain — so recent articles are HIT-ready again right after the deploy ban. CMS publish-webhooks can warm a richer list app-side (see the dotnews `_hooks/cms` receiver: homepage, changed entry, latest articles). ## Layout on the server ``` /usr/local/bin/paas the CLI /srv/paas/apps.d/.conf app registry (edit + `paas regen`) /srv/paas/caddy/*.caddy generated Caddy site blocks /srv/paas/vcl/paas-*.vcl generated Varnish backends/routing /srv/paas/panel/ the web panel (PM2: paas-panel) /srv/paas/builds// panel/CI build workspaces /srv/paas/paas.db sqlite: projects + deployment history /srv/apps//releases/ one dir per deploy /srv/apps//current symlink to the live release /srv/apps//shared/.env app environment (Laravel: the app's .env, symlinked per release) /srv/apps//static/ public/ assets, served by Caddy (accumulating; node apps) /srv/apps//shared/storage/ Laravel storage/ (shared across releases) /srv/paas/php//*.conf generated PHP-FPM pools (laravel servers) /srv/paas/db-admin.cnf MariaDB admin creds for 'paas db' (laravel servers) /srv/paas/backups/db/ nightly per-app database dumps ``` ## Further features - **System domains**: every `paas create` generates `-.` (wildcard DNS → this server; always available, forced noindex). Base in `/srv/paas/paas.conf`. - **Redirect domains**: `paas domains --add-redirect example.com` → 308 to the primary domain (or via the panel's Domains card). - **Geo-blocking**: per-path country rules (block / allow-only) via the panel's rule editor (searchable flag picker, with a Clone button to reuse a rule's countries for a different path) or `paas geo --set-json -`. Enforced in Caddy before the cache; needs the xcaddy build (maxmind module — see bootstrap output) + DB-IP mmdb (auto-refreshed monthly). Internal/unknown IPs always pass — so do Google's crawlers, by default: `paas-google-crawlers-refresh` (systemd timer, daily) fetches Google's official AdsBot-Google/ AdsBot-Google-Mobile/Googlebot IP ranges (`special-crawlers.json` + `common-crawlers.json`) into `/srv/paas/geoip/google-crawlers.cidr`, which `emit_geo_directives` bakes into an app's geo matchers as a `not remote_ip` exemption — geo-blocking a market never breaks Google's indexing/ad-verification crawls of it. Per-app toggle, `GEO_EXEMPT_GOOGLE` (default `on`): `paas set --geo-exempt-google off` or the panel's Geo-blocking card switch makes Google's crawlers subject to the app's geo rules like any other visitor. - **Domain rules**: restrict one of a project's custom domains to a path allowlist (everything else 404s on that host, before Varnish/PM2 ever see it) and/or mark it `noindex` (`X-Robots-Tag`, independent of the app-wide flag) — via the panel's Domain rules editor (Clone button to reuse a rule's paths/noindex on another domain) or `paas domain-rules --set-json -`. Same enforcement mechanism as geo-blocking. Useful for a secondary marketing/campaign domain (e.g. an `invest.` subdomain used only for a handful of landing pages) sharing an app instance with the primary domain without duplicating the whole site onto it or into search results — the app's own canonical/sitemap logic (when host-agnostic, as in the dotnews Nuxt apps) already keeps such a domain out of duplicate-content trouble; this feature only needs to scope which paths and headers apply. - **Traffic metrics**: per-app daily requests/bandwidth/HIT-rate/status from Caddy access logs (zero hot-path impact); 30-day chart + immutable monthly ledger in the panel. - **Checkmk**: agent-over-HTTPS datasource (`GET /cmk-agent`, Bearer token) + dynamic local checks — every app automatically gets health/cert/deploy/pm2/5xx services; deploy failures alert through the existing Checkmk notification rules. Monitoring-side setup is a one-time host + 2 rules (see plan history). - **Matrix projects**: one repo → N apps. Set the project's Variants JSON (`[{"app": "...", "prep_cmd": "..."}]`); each push then preps/builds/deploys every variant with isolated failures. This is the dotnews production pattern. ## Future (design-ready, not built) Per-URL cache tags (xkey), multi-server, preview deploys, panel 2FA. Migrating the four production dotnews sites = 4× `paas create` (+ redirect domains) + one matrix project with the four tenant prep commands.