# Deployment — blut24-landing Push to `main` → Jenkins builds on the target → atomic symlink swap → rolling PM2 reload. No downtime: the previous release serves traffic until the new one has been built **and** health-checked. | | | |---|---| | Repository | `git@github.com:younex1/blut24-landing.git` (branch `main`) | | Jenkins | http://192.168.12.65:8080/ | | Target host | `192.168.12.81` | | Live path | `/var/www/blut24.com/web` → `releases/-` | | Process manager | PM2, cluster mode, 2 workers, `127.0.0.1:3002` | | Front end | nginx → `blut24_landing` upstream | ## Layout on the target ``` /var/www/blut24.com/ ├── web/ │ ├── current -> releases/42-a1b2c3d # atomic symlink; this is the live code │ ├── releases/ # last 5 kept, older pruned automatically │ └── downloads/ # Android APK + update manifests (see below) │ ├── blut24-mobile.apk │ ├── version.json │ ├── history.json │ └── history/blut24-mobile--.apk ├── private/blut24-landing/shared/ │ ├── .env # secrets, never in git, survives deploys │ └── data/ │ ├── registrations.db # REAL DATA — cannot be regenerated │ ├── clinics.db # derived from the XML, rebuilt when stale │ └── *TVERZ_Export.xml # Bundes-Klinik-Atlas source ├── ecosystem.config.cjs # PM2 config, lives outside releases └── logs/ ``` `downloads/` is a **real directory**, not part of any release. A deploy only writes into `releases/` and re-points `current`, so publishing an APK and deploying the site are completely independent operations. **`shared/data` is the important part.** The databases are gitignored, so they are not in the build artifact. Each release's `data/` directory is deleted and replaced with a symlink to `shared/data`. Without that, every deploy would ship an empty `registrations.db` and silently discard every pending clinic registration — the exact failure the ERP deploy script records having hit once with `chat.db`. ## One-time setup ### 1. Target host ```bash scp deploy/bootstrap-server.sh root@192.168.12.81:/tmp/ scp deploy/nginx-blut24.conf root@192.168.12.81:/tmp/ ssh root@192.168.12.81 'bash /tmp/bootstrap-server.sh' ``` Installs Node v22.14.0 (nvm), PM2, nginx, the build toolchain for native modules, the directory layout, and the PM2 systemd unit so the site comes back after a reboot. Then fill in the secrets: ```bash ssh root@192.168.12.81 'vi /var/www/blut24.com/shared/.env' ``` `ERP_SERVICE_TOKEN` is the only value that must be set before clinic onboarding will reach the ERP; until it is, `erpOnboarding.ts` logs and no-ops, and registration still works. ### 2. SSH key for Jenkins ```bash ssh-keygen -t ed25519 -f ~/.ssh/blut24_deploy -C 'jenkins@blut24-landing' -N '' ssh-copy-id -i ~/.ssh/blut24_deploy.pub root@192.168.12.81 ``` In Jenkins → *Manage Jenkins* → *Credentials* → *System* → *Global*: **SSH Username with private key**, ID exactly `blut24-landing-deploy-key`, username `root`, private key = contents of `~/.ssh/blut24_deploy`. The ID must match `SSH_CRED_ID` in the `Jenkinsfile`. ### 3. Jenkins job *New Item* → **Multibranch Pipeline** (or Pipeline) named `blut24-landing`: - **Branch Sources → Git**: `git@github.com:younex1/blut24-landing.git`, credentials = a key with read access to the repo (this is the *GitHub* key, not the deploy key above). - **Behaviours**: *Discover branches*. Optionally restrict to `main`; the `Jenkinsfile` also refuses to deploy anything else, so both layers agree. - **Build Configuration**: by `Jenkinsfile` (root of the repo). - **Scan Triggers**: every 5 minutes is a reasonable fallback alongside the webhook. Required plugins: *Pipeline*, *Git*, *GitHub*, *SSH Agent*, *Timestamper*. ### 4. GitHub webhook Repo → *Settings* → *Webhooks* → *Add webhook*: - Payload URL: `http://192.168.12.65:8080/github-webhook/` (trailing slash required) - Content type: `application/json` - Events: *Just the push event* Jenkins must be reachable from GitHub. If it is only on the LAN, the webhook cannot be delivered — the `pollSCM('H/5 * * * *')` trigger in the `Jenkinsfile` covers that case, at the cost of up to five minutes' latency. For instant builds without exposing Jenkins, either run a webhook relay or add a `post-receive` step on a self-hosted mirror. ## How a deploy runs 1. **Checkout** on Jenkins; release id is `-`. 2. **Guard** — anything other than `main` stops here. 3. **Ship** — `rsync` of the working tree into `releases/` on the target. No git, no GitHub credentials needed on the target. 4. **Build on the target** — `npm ci` then `nuxt build`. This happens on `.81` rather than on Jenkins deliberately: `better-sqlite3` is a native addon compiled against a specific Node ABI, OS and libc. Building it on `.65` and copying it to `.81` works only while the two hosts stay byte-compatible, and fails at *runtime* with `invalid ELF header` when they drift. The script also explicitly loads the addon after install, so a broken native build fails the deploy instead of the site. 5. **Pre-flight** — the new build is booted on port 3999 and must answer `200` on `/`. A build can compile and still crash on start (bad env, missing binding); this catches it while the old release is still live and untouched. 6. **Swap** — `ln -sfn` is *not* atomic (it unlinks, then recreates). The script creates a temporary link and `mv -Tf`s it over `web`, which is a single `rename(2)`, so no request ever observes a missing directory. 7. **Reload** — `pm2 reload` in cluster mode retires workers one at a time; each new worker resolves the `web` symlink afresh and therefore boots the new release. 8. **Verify, or roll back** — if the live check fails, the symlink is swapped back to the previous release and PM2 reloaded again, then the build is marked failed. 9. **Prune** — keeps the newest 5 releases. ## nginx: where the config actually lives `.81` is an **ISPConfig** host. `/etc/nginx/sites-available/blut24.com.vhost` is **generated** — from an ISPConfig template plus the site's *nginx Directives* field, stored in MySQL as `dbispconfig.web_domain.nginx_directives` (`domain_id = 4`). Editing the `.vhost` by hand works until the next regeneration, then silently disappears. That has already happened once. `deploy/nginx-blut24.conf` in this repo is a **reference copy**, kept in sync by hand. The authoritative text lives on the host in two places: | on `.81` | what it is | |---|---| | `/root/blut24-nginx-directives.txt` | the exact directive block, byte-identical to the DB column | | `/root/blut24-update-directives.php` | applies that file to the DB **and** writes the `sys_datalog` row | | `/root/blut24-nginx-backup/` | pre-change backup of the vhost and the old directives | A direct `UPDATE` on the column is **not enough** — ISPConfig only regenerates vhosts for rows in `sys_datalog`, and the entry it expects is a PHP-serialised `{old, new}` snapshot of the whole `web_domain` record. `blut24-update-directives.php` does both (and is a no-op when the column already matches, so it is safe to re-run): ```bash ssh root@192.168.12.81 vi /root/blut24-nginx-directives.txt # edit the block php /root/blut24-update-directives.php # UPDATE + sys_datalog entry /usr/local/ispconfig/server/server.sh # regenerate (cron runs it every minute anyway) grep -n downloads /etc/nginx/sites-available/blut24.com.vhost # confirm it came through nginx -t # ALWAYS before reloading … systemctl reload nginx # … this host also serves logship.de, # logyou.de and smm.contact ``` DB credentials are never hardcoded; the script reads them from ISPConfig's own `/usr/local/ispconfig/server/lib/config.inc.php`. ISPConfig strips `#` comment lines and re-indents when it renders the directives, so the generated vhost is terser than the copy in this repo. That is expected. `server.sh` also logs `PHP Warning: unserialize(): Error at offset …` for the hand-written datalog row — harmless, the nginx plugin re-reads the record from the DB rather than from the snapshot. ## Publishing the Android app The APK is built in the **`erp-nuxt-frontend` repo** (branch `blut24`) — the landing site only links to it. ### Why a plain file drop does not work nginx proxies **everything** on this vhost to the Nuxt app on `127.0.0.1:3002`. A file copied into the docroot is therefore never read from disk: the request goes to Node and comes back 404. Two location blocks fix that (see `deploy/nginx-blut24.conf`): | block | serves | notes | |---|---|---| | `location /downloads/` | `version.json`, `history.json` | from disk, `Cache-Control: max-age=300, must-revalidate` | | `location ~ ^/downloads/…\.apk$` | the APK | regex beats the prefix match, so it wins: `Content-Type: application/vnd.android.package-archive` + `Content-Disposition: attachment` | The short TTL matters: a release overwrites `blut24-mobile.apk` and `version.json` **in place**, so nothing may pin an old copy. ### Releasing From the ERP repo: ```bash cd ../erp-nuxt-frontend # branch blut24 scripts/release-app.sh # auto-bumps the patch version scripts/release-app.sh 1.1.0 # …or set the versionName explicitly scripts/release-app.sh --no-upload # build + stage only ``` That script bumps `android/app/build.gradle` + `android/update-files/version.json`, runs `npm run build:app` → `npm run cap:sync:app` → `gradlew assembleDebug`, stages `android/update-files/blut24-mobile.apk`, records its byte size in `version.json`, and finally calls `scripts/upload-mobile-update.sh --yes`, which rsyncs over SSH to: ``` root@192.168.12.81:/var/www/blut24.com/web/downloads/ ``` Upload order is deliberate — **APK first, `version.json` last** — so the in-app updater never sees a manifest pointing at a half-uploaded file. The APK being replaced is copied to `history/blut24-mobile--.apk` and recorded in `history.json`, which feeds the admin-only "Versionsverlauf" screen in the app. Publishing to a different host, one-off: ```bash SSH_HOST=… REMOTE_DIR=… PUBLIC_BASE_URL=… scripts/upload-mobile-update.sh --yes ``` Environment variables beat `android/update-files/.deploy.env`, which beats the built-in defaults. `UPLOAD_METHOD=sftp` switches to the older interactive SFTP path for hosts without shell access. ### Resulting URLs | | | |---|---| | APK | `https://blut24.com/downloads/blut24-mobile.apk` | | Update manifest | `https://blut24.com/downloads/version.json` | | Release history | `https://blut24.com/downloads/history.json` | `blut24.com` 301-redirects to `www.blut24.com` (ISPConfig's www-canonicalisation), so every client has to follow redirects. `curl` needs `-L`; Android's `DownloadManager` and `fetch()` follow same-scheme redirects on their own. ### Switching the landing-page badge on The badge is deliberately dead until an APK exists. **One line turns it on** — `app/components/AndroidBadge.vue`, in the `withDefaults(...)` call at the end of `