#!/usr/bin/env bash # # dotnews-paas bootstrap — one-shot, idempotent setup of a fresh Debian 12 VM. # Run as root from a checkout/rsync of this repo: # sudo PANEL_DOMAIN=deploy.younex.de PANEL_ADMIN_PASSWORD=... ./bootstrap.sh # # Installs: Node 22, PM2, Caddy, Varnish, sqlite3 + the paas CLI, the deploy # panel, base configs, the deploy user, firewall. Safe to re-run. # # Optional runtimes: # WITH_LARAVEL=1 adds PHP-FPM (PHP_VERSIONS, default "8.3 8.4" from deb.sury.org), # composer, MariaDB (loopback only) + the 'paas db' admin # credentials, sudoers for fpm reloads, the Laravel scheduler # and nightly DB-backup timers, and RUNTIMES=node,laravel in # /srv/paas/paas.conf (what the panel/CLI gate on). Without the # flag nothing of this is touched — a plain Node server. set -euo pipefail PANEL_DOMAIN="${PANEL_DOMAIN:-deploy.younex.de}" ACME_EMAIL="${ACME_EMAIL:-info@logyou.de}" PANEL_PORT="${PANEL_PORT:-3900}" VARNISH_MALLOC="${VARNISH_MALLOC:-2g}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" log() { printf '\n[bootstrap] %s\n' "$*"; } [[ "$(id -u)" -eq 0 ]] || { echo "run as root" >&2; exit 1; } [[ -f "$SCRIPT_DIR/bin/paas" ]] || { echo "run from the dotnews-paas repo root" >&2; exit 1; } # ------------------------------------------------------------- base system ---- log "apt base packages" export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq curl ca-certificates gnupg git rsync jq sqlite3 ufw \ unattended-upgrades build-essential python3 debian-keyring debian-archive-keyring \ apt-transport-https chromium fonts-liberation >/dev/null # chromium: panel homepage snapshots log "unattended security upgrades" printf 'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n' \ >/etc/apt/apt.conf.d/20auto-upgrades # ------------------------------------------------------------------ node 22 ---- if ! command -v node >/dev/null || [[ "$(node -v | cut -c2-3)" -lt 22 ]]; then log "installing Node 22 (NodeSource)" curl -fsSL https://deb.nodesource.com/setup_22.x | bash - >/dev/null apt-get install -y -qq nodejs >/dev/null fi command -v pm2 >/dev/null || { log "installing pm2"; npm install -g pm2 >/dev/null; } # extra Node versions, selectable per app (system Node 22 is the default) log "extra node versions (/opt/node)" install -d /opt/node for NV in 18 20 24; do if [[ ! -d "/opt/node/v$NV" ]]; then TARBALL=$(curl -fsSL "https://nodejs.org/dist/latest-v$NV.x/" | grep -oE "node-v$NV\.[0-9]+\.[0-9]+-linux-x64\.tar\.xz" | head -1) if [[ -n "$TARBALL" ]]; then curl -fsSL "https://nodejs.org/dist/latest-v$NV.x/$TARBALL" | tar -xJ -C /opt/node mv "/opt/node/${TARBALL%.tar.xz}" "/opt/node/v$NV" log " installed node $("/opt/node/v$NV/bin/node" -v)" else log " WARNING: could not resolve latest v$NV tarball — skipped" fi fi done # -------------------------------------------------------------------- caddy ---- if ! command -v caddy >/dev/null; then log "installing Caddy (official repo)" curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ | gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ >/etc/apt/sources.list.d/caddy-stable.list apt-get update -qq && apt-get install -y -qq caddy >/dev/null fi # ------------------------------------------------------------------ varnish ---- command -v varnishd >/dev/null || { log "installing Varnish"; apt-get install -y -qq varnish >/dev/null; } # -------------------------------------------------------------- deploy user ---- if ! id deploy >/dev/null 2>&1; then log "creating deploy user" useradd -m -s /bin/bash deploy fi install -d -m 700 -o deploy -g deploy /home/deploy/.ssh # authorize the same keys as root (so whoever bootstraps can ssh as deploy) if [[ -f /root/.ssh/authorized_keys ]]; then touch /home/deploy/.ssh/authorized_keys while IFS= read -r k; do grep -qF "$k" /home/deploy/.ssh/authorized_keys || echo "$k" >>/home/deploy/.ssh/authorized_keys done /dev/null fi log "sudoers for deploy (service reloads + varnish validation only)" cat >/etc/sudoers.d/paas <<'EOF' deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload caddy, /usr/bin/systemctl restart caddy, /usr/bin/systemctl reload varnish, /usr/bin/systemctl restart varnish, /usr/sbin/varnishd *, /usr/local/bin/paas-cert-prune * EOF chmod 440 /etc/sudoers.d/paas # platform self-update over SSH (Jenkins job `paas-platform`, see # ci/Jenkinsfile.platform + docs/ci-platform-deploy.md): root script + forced # command wrapper + exact-command sudoers. Inert until infra/setup-ci-deploy.sh # authorizes a CI key on the deploy user — no instance state here. log "platform self-deploy (paas-self-deploy + sudoers, used by CI)" install -m 0755 "$SCRIPT_DIR/infra/paas-self-deploy" /usr/local/sbin/paas-self-deploy install -m 0755 "$SCRIPT_DIR/infra/paas-ci-ssh" /usr/local/bin/paas-ci-ssh cat >/etc/sudoers.d/paas-ci <<'EOF' deploy ALL=(root) NOPASSWD: /usr/local/sbin/paas-self-deploy * EOF chmod 440 /etc/sudoers.d/paas-ci # ------------------------------------------------------------- directories ---- log "directory skeleton" install -d -o deploy -g deploy /srv/paas /srv/paas/apps.d /srv/paas/caddy \ /srv/paas/vcl /srv/paas/builds /srv/paas/logs /srv/paas/snapshots /srv/apps chmod 755 /srv/paas /srv/paas/vcl /srv/paas/caddy # varnish/caddy must read # ----------------------------------------------------------------- database ---- log "sqlite schema" install -d -o deploy -g deploy /srv/paas/sql install -m 644 -o deploy -g deploy "$SCRIPT_DIR/sql/schema.sql" /srv/paas/sql/schema.sql sudo -u deploy -H bash -c 'cd /srv/paas && sqlite3 /srv/paas/paas.db' <"$SCRIPT_DIR/sql/schema.sql" # ----------------------------------------------------------------- paas CLI ---- log "installing paas CLI" install -m 755 "$SCRIPT_DIR/bin/paas" /usr/local/bin/paas install -m 755 "$SCRIPT_DIR/bin/paas-cert-prune" /usr/local/bin/paas-cert-prune install -m 755 "$SCRIPT_DIR/bin/paas-init" /usr/local/bin/paas-init # first-boot wizard (golden clones) if [[ ! -f /srv/paas/paas.conf ]]; then cat >/srv/paas/paas.conf < this server) SYSTEM_DOMAIN_BASE=${SYSTEM_DOMAIN_BASE:-younex.de} EOF chown deploy:deploy /srv/paas/paas.conf fi # ----------------------------------------------------- laravel runtime (opt-in) ---- if [[ "${WITH_LARAVEL:-0}" == "1" ]]; then PHP_VERSIONS="${PHP_VERSIONS:-8.3 8.4}" PHP_DEFAULT="${PHP_DEFAULT:-${PHP_VERSIONS%% *}}" MARIADB_BUFFER_POOL="${MARIADB_BUFFER_POOL:-512M}" log "laravel runtime: PHP ($PHP_VERSIONS) from deb.sury.org" if [[ ! -f /etc/apt/sources.list.d/sury-php.list ]]; then # shellcheck disable=SC1091 . /etc/os-release curl -fsSL https://packages.sury.org/php/apt.gpg | gpg --dearmor --yes -o /usr/share/keyrings/deb.sury.org-php.gpg echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ $VERSION_CODENAME main" \ >/etc/apt/sources.list.d/sury-php.list apt-get update -qq fi install -d -o deploy -g deploy -m 755 /srv/paas/php /srv/paas/php/bin for V in $PHP_VERSIONS; do # explicit packages only — the "php$V" meta package would drag in Apache apt-get install -y -qq "php$V-fpm" "php$V-cli" "php$V-mysql" "php$V-mbstring" "php$V-xml" \ "php$V-curl" "php$V-zip" "php$V-bcmath" "php$V-intl" "php$V-gd" "php$V-opcache" \ "php$V-readline" "php$V-sqlite3" "php$V-redis" >/dev/null # per-app pools written by 'paas' (user deploy) live outside /etc install -d -o deploy -g deploy -m 755 "/srv/paas/php/$V" "/srv/paas/php/bin/$V" ln -sfn "/usr/bin/php$V" "/srv/paas/php/bin/$V/php" # PATH shim for builds/artisan FPMCONF="/etc/php/$V/fpm/php-fpm.conf" grep -q "^include=/srv/paas/php/$V/\*.conf" "$FPMCONF" || echo "include=/srv/paas/php/$V/*.conf" >>"$FPMCONF" # let in-flight requests finish on the (rare) reload if grep -q '^;*process_control_timeout' "$FPMCONF"; then sed -i 's/^;*process_control_timeout.*/process_control_timeout = 10s/' "$FPMCONF" else sed -i '/^\[global\]/a process_control_timeout = 10s' "$FPMCONF" fi # opcache belongs to the fpm master (shared by all pools) — per-pool # values are ignored, so it is configured once per version here. # validate_timestamps stays on: 'paas restart' regenerates caches in place. cat >"/etc/php/$V/fpm/conf.d/99-paas.ini" <<'EOF' ; dotnews-paas — written by bootstrap.sh opcache.enable=1 opcache.memory_consumption=256 opcache.interned_strings_buffer=32 opcache.max_accelerated_files=50000 opcache.validate_timestamps=1 opcache.revalidate_freq=2 expose_php=Off EOF "/usr/sbin/php-fpm$V" -t >/dev/null systemctl enable --now "php$V-fpm" >/dev/null 2>&1 systemctl reload "php$V-fpm" log " php $V ready ($("/usr/bin/php$V" -r 'echo PHP_VERSION;'))" done log "composer" if ! command -v composer >/dev/null; then EXPECTED="$(curl -fsSL https://composer.github.io/installer.sig)" curl -fsSL https://getcomposer.org/installer -o /tmp/composer-setup.php ACTUAL="$("/usr/bin/php$PHP_DEFAULT" -r "echo hash_file('sha384', '/tmp/composer-setup.php');")" [[ "$EXPECTED" == "$ACTUAL" ]] || { rm -f /tmp/composer-setup.php; echo "composer installer checksum mismatch" >&2; exit 1; } "/usr/bin/php$PHP_DEFAULT" /tmp/composer-setup.php --quiet --install-dir=/usr/local/bin --filename=composer rm -f /tmp/composer-setup.php else sudo -u deploy -H composer self-update -q 2>/dev/null || composer self-update -q 2>/dev/null || true fi install -d -o deploy -g deploy /srv/paas/builds/.composer log "mariadb (loopback only)" command -v mariadbd >/dev/null || apt-get install -y -qq mariadb-server mariadb-client >/dev/null MARIADB_CNF=/etc/mysql/mariadb.conf.d/60-paas.cnf MARIADB_NEW="$(cat <"$MARIADB_CNF" systemctl enable --now mariadb >/dev/null 2>&1 systemctl restart mariadb else systemctl enable --now mariadb >/dev/null 2>&1 fi # 'paas db' admin user: explicit privilege list (no FILE/SUPER), credentials # readable by deploy only. Re-running re-applies the password from the cnf. DB_ADMIN_CNF=/srv/paas/db-admin.cnf if [[ -f "$DB_ADMIN_CNF" ]]; then DB_ADMIN_PW="$(sed -n 's/^password=//p' "$DB_ADMIN_CNF")" else DB_ADMIN_PW="$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 40)" fi mariadb <"$DB_ADMIN_CNF" </etc/sudoers.d/paas-laravel chmod 440 /etc/sudoers.d/paas-laravel visudo -cf /etc/sudoers.d/paas-laravel >/dev/null log "laravel scheduler (1/min) + database backups (nightly) timers" install -m 755 "$SCRIPT_DIR/bin/paas-laravel-scheduler" /usr/local/bin/paas-laravel-scheduler install -m 644 "$SCRIPT_DIR/infra/paas-laravel-scheduler.service" /etc/systemd/system/ install -m 644 "$SCRIPT_DIR/infra/paas-laravel-scheduler.timer" /etc/systemd/system/ install -m 644 "$SCRIPT_DIR/infra/paas-db-backup.service" /etc/systemd/system/ install -m 644 "$SCRIPT_DIR/infra/paas-db-backup.timer" /etc/systemd/system/ systemctl daemon-reload systemctl enable --now paas-laravel-scheduler.timer >/dev/null 2>&1 systemctl enable --now paas-db-backup.timer >/dev/null 2>&1 # capability flags the CLI + panel gate on set_paas_conf() { # $1 key, $2 value if grep -q "^$1=" /srv/paas/paas.conf; then sed -i "s|^$1=.*|$1=$2|" /srv/paas/paas.conf; else echo "$1=$2" >>/srv/paas/paas.conf; fi } set_paas_conf RUNTIMES "node,laravel" set_paas_conf PHP_VERSIONS "\"$PHP_VERSIONS\"" set_paas_conf PHP_DEFAULT "$PHP_DEFAULT" log "laravel runtime enabled (RUNTIMES=node,laravel in /srv/paas/paas.conf)" fi # --------------------------------------------------------- checkmk monitoring ---- log "checkmk local checks (agent deb itself: install manually from the monitoring site)" install -d /usr/lib/check_mk_agent/local install -m 755 "$SCRIPT_DIR/agent/local-checks-paas" /usr/lib/check_mk_agent/local/paas cat >/etc/sudoers.d/paas-cmk <<'EOF' deploy ALL=(root) NOPASSWD: /usr/bin/check_mk_agent EOF chmod 440 /etc/sudoers.d/paas-cmk # ---------------------------------------------------------- traffic metrics ---- log "traffic rollup (caddy access logs -> sqlite, systemd timer)" # caddy (service) and deploy (validate/rollup) both need rw on the log files: # shared setgid dir + 0664 file mode + caddy in the deploy group. usermod -aG deploy caddy 2>/dev/null || true install -d -o caddy -g deploy -m 2770 /srv/paas/logs/caddy install -m 755 "$SCRIPT_DIR/bin/paas-traffic-rollup" /usr/local/bin/paas-traffic-rollup install -m 644 "$SCRIPT_DIR/infra/paas-traffic-rollup.service" /etc/systemd/system/ install -m 644 "$SCRIPT_DIR/infra/paas-traffic-rollup.timer" /etc/systemd/system/ systemctl daemon-reload systemctl enable --now paas-traffic-rollup.timer >/dev/null 2>&1 # same rollup pass also aggregates article_clicks_daily (opt-in per app via # route-manifest.json); this second timer pushes the totals to each app log "click-count writeback (article_clicks_daily -> each app's Strapi, hourly)" install -m 755 "$SCRIPT_DIR/bin/paas-clicks-writeback" /usr/local/bin/paas-clicks-writeback install -m 644 "$SCRIPT_DIR/infra/paas-clicks-writeback.service" /etc/systemd/system/ install -m 644 "$SCRIPT_DIR/infra/paas-clicks-writeback.timer" /etc/systemd/system/ systemctl daemon-reload systemctl enable --now paas-clicks-writeback.timer >/dev/null 2>&1 # ------------------------------------------------------------ geo-blocking ---- log "geoip refresh (DB-IP country mmdb, monthly)" install -m 755 "$SCRIPT_DIR/bin/paas-geoip-refresh" /usr/local/bin/paas-geoip-refresh install -m 644 "$SCRIPT_DIR/infra/paas-geoip-refresh.service" /etc/systemd/system/ install -m 644 "$SCRIPT_DIR/infra/paas-geoip-refresh.timer" /etc/systemd/system/ systemctl daemon-reload systemctl enable --now paas-geoip-refresh.timer >/dev/null 2>&1 [[ -f /srv/paas/geoip/country.mmdb ]] || /usr/local/bin/paas-geoip-refresh || true if [[ ! -x /usr/local/bin/caddy ]]; then log "NOTE: geo-blocking needs the xcaddy-built caddy (maxmind module):" log " apt-get install -y golang-go && go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest" log " xcaddy build \$(caddy version | cut -d' ' -f1) --with github.com/porech/caddy-maxmind-geolocation --output /usr/local/bin/caddy" log " + systemd override ExecStart/ExecReload -> /usr/local/bin/caddy (see docs/README.md)" fi log "google crawler IP refresh (AdsBot/Googlebot exemption from geo-blocking, daily)" install -m 755 "$SCRIPT_DIR/bin/paas-google-crawlers-refresh" /usr/local/bin/paas-google-crawlers-refresh install -m 644 "$SCRIPT_DIR/infra/paas-google-crawlers-refresh.service" /etc/systemd/system/ install -m 644 "$SCRIPT_DIR/infra/paas-google-crawlers-refresh.timer" /etc/systemd/system/ systemctl daemon-reload systemctl enable --now paas-google-crawlers-refresh.timer >/dev/null 2>&1 [[ -f /srv/paas/geoip/google-crawlers.cidr ]] || /usr/local/bin/paas-google-crawlers-refresh || true # ------------------------------------------------------------ varnish config ---- log "varnish base config" install -m 644 "$SCRIPT_DIR/templates/base.vcl" /etc/varnish/default.vcl if [[ ! -f /srv/paas/vcl/paas-backends.vcl ]]; then echo "backend b_paas_none none;" >/srv/paas/vcl/paas-backends.vcl echo 'return (synth(421, "Misdirected Request"));' >/srv/paas/vcl/paas-routing.vcl chown deploy:deploy /srv/paas/vcl/paas-*.vcl fi install -d /etc/systemd/system/varnish.service.d cat >/etc/systemd/system/varnish.service.d/override.conf </dev/null 2>&1 systemctl restart varnish # -------------------------------------------------------------- caddy config ---- log "caddy base config" sed "s/{\$ACME_EMAIL:info@logyou.de}/$ACME_EMAIL/" "$SCRIPT_DIR/templates/Caddyfile" >/etc/caddy/Caddyfile if [[ ! -f /srv/paas/caddy/panel.caddy ]]; then cat >/srv/paas/caddy/panel.caddy </dev/null 2>&1 caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null systemctl reload caddy # -------------------------------------------------------------------- panel ---- log "installing deploy panel" rsync -a --delete --exclude node_modules --exclude .env "$SCRIPT_DIR/panel/" /srv/paas/panel/ chown -R deploy:deploy /srv/paas/panel sudo -u deploy bash -c 'cd /srv/paas/panel && (npm ci --omit=dev >/dev/null 2>&1 || npm install --omit=dev >/dev/null)' if [[ ! -f /srv/paas/panel/.env ]]; then if [[ -z "${PANEL_ADMIN_PASSWORD:-}" ]]; then read -rsp "[bootstrap] choose a panel admin password: " PANEL_ADMIN_PASSWORD; echo fi HASH="$(sudo -u deploy node /srv/paas/panel/bin/hash-password.js "$PANEL_ADMIN_PASSWORD")" cat >/srv/paas/panel/.env </srv/paas/panel/run.sh <<'EOF' #!/usr/bin/env bash set -a; . /srv/paas/panel/.env; set +a cd /srv/paas/panel exec node server.js EOF chmod +x /srv/paas/panel/run.sh chown deploy:deploy /srv/paas/panel/run.sh log "registering panel with pm2 (user deploy)" # -H + cd: pm2 must not inherit a cwd the deploy user cannot access sudo -u deploy -H bash -c 'cd /srv/paas/panel && { pm2 describe paas-panel >/dev/null 2>&1 && pm2 restart paas-panel || pm2 start /srv/paas/panel/run.sh --name paas-panel --interpreter bash --time; }' >/dev/null sudo -u deploy -H bash -c 'cd /srv/paas/panel && pm2 save' >/dev/null env PATH="$PATH" pm2 startup systemd -u deploy --hp /home/deploy >/dev/null # ----------------------------------------------------------------- firewall ---- log "firewall (22, 80/tcp, 443/tcp+udp)" ufw allow 22/tcp >/dev/null ufw allow 80/tcp >/dev/null ufw allow 443/tcp >/dev/null # 443/udp = HTTP/3 (QUIC). Caddy listens on UDP 443 by default and advertises # h3 via Alt-Svc — with only the tcp rule, QUIC died silently at this host # firewall even though OPNsense forwarded it (found 2026-08-20). ufw allow 443/udp >/dev/null ufw --force enable >/dev/null # converge generated configs (fpm pools need the sudoers + paas.conf from above) if [[ "${WITH_LARAVEL:-0}" == "1" ]]; then sudo -u deploy -H bash -c 'cd /srv/paas && /usr/local/bin/paas regen' >/dev/null fi log "DONE. panel: https://$PANEL_DOMAIN (after DNS points here) deploy key: $(cat /home/deploy/.ssh/id_ed25519.pub) runtimes: $(grep -s '^RUNTIMES=' /srv/paas/paas.conf | cut -d= -f2 || echo node) next: ssh deploy@ -> paas create --domains [--cache] [--runtime laravel]"