Skip to main content

Runtime aplikacji: Node + SQLite na VPS

Od 10.09.2026 wszystkie środowiska (dev, qa, produkcja) działają jako zwykły proces Node na serwerze VPS, z bazą SQLite na dysku. Ten dokument opisuje, co to oznacza dla użytkownika, i jak zbudowany jest runtime — od entrypointu, przez warstwę lib/runtime, po crony i kopie zapasowe. Infrastruktura wokół aplikacji (Coolify, tunel, GlitchTip, Healthchecks, backupy R2) jest opisana w docs/infrastruktura-vps.md; historia migracji z Cloudflare Workers — w planie docs/plans/migracja-vps-sqlite-coolify.md.

👤 Dla użytkownika​

Adresy​

ŚrodowiskoAdresGałąźKto korzysta
produkcjaklient.acepark.plmainklienci, recepcja, trenerzy, urządzenia Pi
qaqa.acepark.plprod_devklient testowy, próby przed wydaniem
devdev.acepark.pldevelopzespół

Panel pracownika i portal klienta to jedna aplikacja pod jednym adresem: https://klient.acepark.pl/dashboard. Stary adres panel.acepark.pl przekierowuje na klient.acepark.pl z zachowaniem ścieżki, więc zapisane zakładki dalej działają.

Co się dzieje przy wdrożeniu​

Każde wdrożenie to krótka przerwa: stary kontener jest zatrzymywany, nowy startuje, wykonuje migracje bazy i dopiero wtedy przyjmuje ruch. W tym czasie strona pokazuje komunikat o przerwie technicznej (po polsku i angielsku) i sama wraca, gdy aplikacja wstanie — zwykle po kilkunastu sekundach, w najgorszym razie po około dwóch minutach. Żądania, które były w toku (płatność, zapis na zajęcia), są dokończone przed zatrzymaniem.

Wdrożenia produkcyjne robimy poza godzinami pracy recepcji i poza godzinami cronów dziennych.

Gdzie zgłaszać problemy​

Przycisk „Zgłoś błąd” na ekranie błędu tworzy zgłoszenie w Jirze z linkiem do zdarzenia w GlitchTipie — patrz GlitchTip. Stan cronów (przypomnienia, raporty) widać w Healthchecks (hc.acepark.pl).

🔧 Dokumentacja techniczna​

Procesy​

Jeden obraz Dockera (Dockerfile, node:24-bookworm-slim), trzy kontenery z docker-compose.yml:

KontenerPolecenieRola
webnode dist/server.jsserver.ts: Next.js (App Router) + obsługa WebSocketów /api/hardware/ws i /api/hardware/viewer-ws przed Next + wewnętrzne API hardware
cronnode dist/cron.jscron.ts: harmonogram z lib/cron/jobs.ts (croner, Europe/Warsaw), catch-up po restarcie, pingi Healthchecks
litestreamlitestream replicateciągła replikacja acepark.db do R2 (segmenty co 1 s, snapshot co 24 h, retencja 30 dni)

Oba pliki dist/*.js powstają z yarn build:server (esbuild, --packages=external); yarn build buduje Next.js. Obraz zawiera .next, dist, migrations, scripts/migrate.mjs i scripts/snapshot-db.mjs.

Start kontenera​

docker-entrypoint.sh uruchamia migracje tylko w kontenerze web (kontener cron startuje dopiero, gdy web jest healthy, więc schemat ma jednego pisarza):

  1. scripts/snapshot-db.mjs — kopia VACUUM INTO /data/snapshots/pre-deploy-<stamp>.db przed jakąkolwiek zmianą schematu,
  2. scripts/migrate.mjs --database /data/acepark.db --allow-multiple — stosuje brakujące pliki z migrations/ w transakcji per plik, historia w tabeli d1_migrations (Migracje bazy danych),
  3. exec node dist/server.js.

Healthcheck (GET /api/health, w obrazie i w compose) odpowiada {"status":"ok","runtime":"node","release":"<SHA>","origin":…} dopiero, gdy baza odpowiada na SELECT 1 — Coolify przełącza ruch na nowy kontener po tym sygnale.

Zatrzymanie: drenowanie​

Oba procesy obsługują SIGTERM. web zamyka nasłuch, odpowiada 503 z Retry-After na nowe żądania i czeka do 50 s (DRAIN_TIMEOUT_MS) na żądania w locie; cron nie startuje nowych zadań i czeka do 170 s na trwające. Dopiero potem closeNodeRuntime() zamyka SQLite. Limity są celowo niższe niż stop_grace_period w compose (60 s / 180 s), po którym Docker wysyła SIGKILL.

Warstwa runtime (lib/runtime/**)​

Tylko ten katalog wie, że pod spodem jest SQLite. Reszta aplikacji używa API w kształcie D1 (prepare().bind().first()/all()/run(), batch()) i KV (get/put/list/delete), zadeklarowanego globalnie w types/runtime-env.d.ts.

PlikRola
env.tsgetRuntimeEnv() → { DB, APP_CACHE }; runInBackground(promise) oddaje pracę do after() z next/server (poza żądaniem promise leci samodzielnie); getAppRuntime() zwraca 'node' (raportowane w /api/health)
node-env.tsjednorazowe otwarcie obu baz (symbol na globalThis, więc przeżywa HMR), closeNodeRuntime()
sqlite-driver.tsopenNodeSqlite(path) nad node:sqlite (DatabaseSync), cache 256 przygotowanych zapytań, transaction() z BEGIN IMMEDIATE
sqlite.tsPRAGMA: journal_mode=WAL, synchronous=NORMAL, busy_timeout=5000, foreign_keys=ON, mmap_size=256 MB, cache_size=64 MB
d1-sqlite.tsshim D1: SqliteD1Database, SqliteD1PreparedStatement, batch() w jednej transakcji, D1Result.meta z changes/last_row_id
kv-sqlite.tsshim KV na cache.db (tabela kv z expires_at); cron kv-expire co 5 min usuwa przeterminowane wpisy
kysely-dialect.tsdialekt Kysely dla Better Auth nad tym samym sterownikiem (jedno połączenie, jedna transakcja)
drain.ts, maintenance.tsdrenowanie żądań i tryb przerwy technicznej

synchronous=NORMAL jest na tym hoście obowiązkowe: fsync trwa ~220 ms, a w trybie FULL każdy zapis czekałby na dysk. WAL daje równoległe odczyty przy jednym pisarzu; busy_timeout 5 s pokrywa krótkie blokady, a błędy SQLITE_BUSY są ponawiane przez withD1Retry (Odporność bazy).

yarn bench:runtime (scripts/bench/runtime-shim.ts) mierzy shim: first() po kluczu głównym ok. 7 µs, all() 1 000 wierszy ok. 1,7 ms, batch() 1 000 insertów ok. 1,9 ms, kv get() 12 µs.

Jeden host, dwie role​

Aplikacja serwuje portal klienta i panel pracownika z jednego procesu. Podział na role (client / admin) wynika z hosta żądania, nie z buildu: CLIENT_HOSTS i ADMIN_HOSTS w zmiennych środowiska. Host spoza obu list (tak działa dziś produkcja, qa i dev) serwuje całą aplikację bez przekierowań. Mechanizm, plik scripts/route-variants.json i handoff sesji między hostami opisuje Podział CLIENT/ADMIN po hoście.

Zmienne środowiska​

Kontener dostaje wyłącznie zmienne wymienione w docker-compose.yml (Coolify nie wstrzykuje niczego automatycznie). Najważniejsze grupy:

GrupaZmienne
baza i cacheDATABASE_PATH=/data/acepark.db, CACHE_DATABASE_PATH=/data/cache.db, BACKUP_DIR=/data/backups
adresyAPP_BASE_URL, BETTER_AUTH_URL, NEXT_PUBLIC_BASE_URL (build arg), CLIENT_HOSTS, ADMIN_HOSTS, CLIENT_ORIGIN, ADMIN_ORIGIN
wersjaAPP_RELEASE=${SOURCE_COMMIT} — SHA wdrażanego commita (release w GlitchTipie, pole release w /api/health)
monitoringSENTRY_DSN, SENTRY_ENVIRONMENT, GLITCHTIP_URL, HEALTHCHECKS_PING_URL, HEALTHCHECKS_API_URL, HEALTHCHECKS_SLUG_PREFIX
hardwareINTERNAL_API_TOKEN, HARDWARE_CONTROL_URL (cron → web)
środowiskoREPORT_ENVIRONMENT (production włącza wysyłkę raportu braków dokumentów), P24_SANDBOX, TZ=UTC

APP_RUNTIME=node w compose i Dockerfile jest pozostałością po okresie dwóch runtime'ów — aplikacja go nie czyta.

Crony​

lib/cron/jobs.ts to jedyne źródło prawdy: 15 zadań aplikacyjnych (przypomnienia, płatności, raporty, hardware, KSeF, Meta) i 4 utrzymaniowe:

ZadanieHarmonogramCo robi
kv-expireco 5 minusuwa przeterminowane klucze z cache.db
logs-retention03:00kasuje wiersze logs starsze niż 90 dni (partiami)
db-snapshot03:00VACUUM INTO do BACKUP_DIR, lokalna retencja 14 dni
db-optimize05:00PRAGMA optimize

Każde uruchomienie pinguje Healthchecks (/start, sukces, /fail); checki tworzy i aktualizuje node dist/cron.js list --sync-healthchecks (uruchamiane po dodaniu zadania). Zadanie dzienne, którego termin minął w ciągu ostatnich 6 h przed startem procesu i nie ma nowszego last_run_at, jest nadrabiane od razu — wdrożenie w minucie triggera nie gubi biegu. Szczegóły i polecenia CLI: Powiadomienia → Zaplanowane zadania.

Kopie zapasowe i odtwarzanie​

WarstwaMechanizmOdtworzenie
ciągła (PITR, 30 dni)Litestream → R2 acepark-backups/litestream/<zasób>litestream restore -o /tmp/restore.db -config litestream.yml /data/acepark.db (ok. 6 s)
dzienna, lokalnacron db-snapshot → /data/backups/*.db (14 dni)skopiować plik na miejsce acepark.db przy zatrzymanym stosie
przed każdym wdrożeniempre-deploy-<stamp>.db w /data/snapshotsjak wyżej — to kopia sprzed migracji, gdyby migracja zepsuła dane

Przywrócenie poprzedniej wersji kodu to w Coolify „Redeploy” wcześniejszego wdrożenia (obraz jest w cache) — patrz RELEASE_GUIDELINES.md. Migracje są tylko „do przodu”, więc cofnięcie kodu po migracji zmieniającej schemat wymaga też odtworzenia bazy z pre-deploy-*.db.

Lokalnie​

yarn db:migrate # tworzy ./.data/dev.db z pełnego zestawu migracji
yarn dev # Next.js dev na ./.data/dev.db (bez WebSocketów Pi)
yarn dev:hardware # tsx server.ts — pełny entrypoint z WebSocketami
yarn dev:cron # scheduler lokalnie; `npx tsx cron.ts run <zadanie>` odpala jedno zadanie
yarn build && yarn build:server && yarn start:server # to, co robi obraz

DATABASE_PATH wskazuje inny plik (np. import z produkcji). Wybór pliku opisuje scripts/lib/local-database.js: DATABASE_PATH, a gdy nie ustawione — ./.data/dev.db, jeśli istnieje.

CI​

ci-pipeline.yml: Code Formatting Check, Route Variant Classification (yarn variants:check), Build (yarn build + yarn build:server); test-pipeline.yml: yarn test. Lokalnie to samo robi yarn ci:local. Wdrożenie wykonuje Coolify po pushu na gałąź środowiska (webhook GitHub App), budując obraz z Dockerfile na serwerze.