# 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/<build>-<sha>` |
| 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-<name>-<code>.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 `<build>-<short sha>`.
2. **Guard** — anything other than `main` stops here.
3. **Ship** — `rsync` of the working tree into `releases/<id>` 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-<name>-<code>.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
`<script setup>`:

```ts
}>(), { variant: 'default', available: false })
//                          ^^^^^^^^^^^^^^^^ -> available: true
```

Neither `SectionApp.vue` nor `SiteFooter.vue` passes the prop, so that single default
governs both badges. Deploy the landing site afterwards.

Independently of that flag, `SectionApp.vue` fetches `/downloads/version.json`
**client-side** and, if it is there, prints `Version 1.0.38 · 21,9 MB · Stand 28.07.2026`
under the badge. No build-time coupling: before the first release the file 404s, the
fetch quietly yields `null`, and the old "wird nach der Freigabe veröffentlicht" line
shows instead.

### Verifying by hand

```bash
curl -sSIL https://blut24.com/downloads/blut24-mobile.apk | grep -iE 'HTTP|content-type|content-disposition'
# HTTP/2 301          ← blut24.com -> www.blut24.com
# HTTP/2 200
# content-type: application/vnd.android.package-archive
# content-disposition: attachment; filename="blut24-mobile.apk"

curl -sSL https://blut24.com/downloads/version.json
```

A `content-type: text/html` with `x-powered-by: Nuxt` means the request was proxied —
the `/downloads/` location is missing from the live vhost (see the nginx section above).

## Operating it

```bash
ssh root@192.168.12.81

readlink -f /var/www/blut24.com/web     # which release is live
pm2 list                                 # worker state
pm2 logs blut24-landing --lines 100
pm2 reload blut24-landing                # zero-downtime restart, no deploy

# manual rollback to the previous release
cd /var/www/blut24.com
ln -sfn "releases/$(ls -1dt releases/*/ | sed -n 2p | xargs basename)" .web.tmp && mv -Tf .web.tmp web
pm2 reload ecosystem.config.cjs --update-env
```

Re-running a deploy is safe and idempotent: a fresh release directory is created each time,
`shared/` is never rewritten, and `clinics.db` is only rebuilt when the source XML is newer.

## Known limits

- **The deploy has not been executed end-to-end yet.** The scripts are syntax-checked and
  the pipeline logic is reviewed, but nothing has run against `.81` — the first build is the
  real test. Run it manually once (*Build Now*) before relying on the webhook.
- `bootstrap-server.sh` assumes Debian/Ubuntu (`apt-get`). On another distro, install
  `python3 make g++ rsync nginx` yourself; the rest is distro-agnostic.
- `bootstrap-server.sh` / `nginx-blut24.conf` describe a **standalone** nginx site. The
  live host runs ISPConfig and generates the vhost instead — see *nginx: where the config
  actually lives*. TLS is already in place there (Let's Encrypt, via ISPConfig).
- **No APK has been published yet.** The `/downloads/` location is live and verified with
  probe files, and `downloads/` exists and is empty; the first real artefact arrives with
  the first `scripts/release-app.sh` run. Until then the badge stays disabled.
- PM2 runs as `root`, matching the ERP host's convention. A dedicated unprivileged user
  would be better; it would need ownership of `/var/www/blut24.com` and its own PM2 systemd
  unit.
