Skip to content

Architecture

Theodor Dimitriou edited this page Sep 24, 2026 · 2 revisions

How AstroBaaS is put together: the single Node process, the path a request takes through the middleware, the storage drivers, media handling, background work, extensions, health checks and shutdown. It is for operators who want to understand what they are running and for contributors finding their way around src/.

The short design notes in the repository are architecture.md and PLATFORM.md.

One process

AstroBaaS is one Astro application, rendered on the server by the @astrojs/node adapter in standalone mode (output: 'server' in astro.config.ts). npm run build produces dist/, and npm start runs node ./dist/server/entry.mjs, which listens on PORT (4321 in the Docker image). That one process serves three things:

  • the admin under /admin, the screens operators work in;
  • the JSON API under /api, for headless frontends, scripts and agents;
  • public pages: the blog, pages, the catalogue, feeds, the sitemap and OG images, for installs that use the built-in front end.

There is no build step between writing content and serving it. A request reads the database and renders, so publishing is a write, not a deploy. The same process also runs the scheduler for background jobs.

                 browser / storefront / script / agent
                                  │
                         reverse proxy (TLS)          nginx or Caddy
                                  │
┌─────────────────────────────────┼──────────────────────────────────┐
│ Node process                    ▼                                  │
│  ┌───────────────────────────────────────────────────────────┐     │
│  │ middleware: observe → main (src/middleware.ts)            │     │
│  │  metrics · CORS · maintenance · auth · CSRF · rate limits │     │
│  └───────────────┬───────────────────────────┬───────────────┘     │
│                  ▼                           ▼                     │
│   routes: /admin · /api · public pages   /uploads/* (files)        │
│                  │                           │                     │
│                  ▼                           │                     │
│   LocalDB (Storage interface) ── plugins, themes, content types    │
│                  │                           │                     │
│   scheduler (one leader per database) ───────┤                     │
└──────────────────┼───────────────────────────┼─────────────────────┘
                   ▼                           ▼
   lowdb JSON file │ libSQL file │ Turso    UPLOADS_DIR, PRIVATE_UPLOADS_DIR

It is not a static site generator, not multi-tenant (one install serves one site), and not clustered by default. Those limits are deliberate; see multiple processes below.

The request pipeline

src/middleware.ts is the single gate every request passes, composed as sequence(observe, main). Everything that must be true of every request lives there, so a new route cannot forget it.

observe runs outermost. It counts the request as in flight (so a shutdown can wait for it), times it for the metrics histogram, records its status class, and writes the request log line when enabled.

main then runs these steps in order:

Step What happens
Locale prefix /de/blog is served by the /blog route with the locale set to de.
CORS preflight OPTIONS to /api is answered from CORS_ORIGINS, before anything else, so a storefront can read even a maintenance answer.
Maintenance (environment) With MAINTENANCE_MODE set, requests get a 503 page (or JSON on /api), without touching the database. The health probes, the admin and sign-in stay open.
Redirects Redirect rules an operator stored are applied to public paths (never /api or /admin).
Session The astrobaas_session cookie is resolved to a user, re-checked against the database.
API key On /api, an Authorization: Bearer abk_… header is resolved to the key's role and scopes. Expired keys authenticate as nobody.
Client address The caller's IP, from the socket or, with TRUST_PROXY=1, the right-most X-Forwarded-For entry. A key marked forward_client_ip may name the shopper's address instead.
Crawler policy Public pages only: a plugin may refuse a user agent.
Rate limits On /api: per key, per signed-in person, or per address, plus per-route budgets. Refusals are 429 with Retry-After.
Body limits Writes without Content-Length get 411; bodies above the route's ceiling get 413, before anything is read.
Maintenance (scheduled) A maintenance window an admin scheduled in settings. Signed-in staff still see the live site.
Commerce switch While commerce is off, commerce API paths answer the same 404 as an unknown path.
Admin gate /admin screens are checked against the role's access list.
API authorisation Scopes are enforced; writes and non-public reads need a session or key (401 otherwise); cookie-authenticated writes need a matching CSRF token (403 CSRF_FAILED).
Route The Astro page or endpoint runs.
Decoration Security headers, X-Frame-Options, CORS headers, RateLimit-* headers, and the CSRF cookie on HTML pages.

API routes answer with one envelope, built by src/lib/api-response.ts: { success, data, meta } or { success: false, error }. See Headless and REST API.

Security headers and CSP

Every response gets X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, a restrictive Permissions-Policy, and X-Frame-Options: DENY (relaxed to SAMEORIGIN for PDFs under /uploads, so the built-in viewer can frame them). Strict-Transport-Security is opt-in with HSTS_MAX_AGE, because sending it from a host that is not fully HTTPS locks visitors out.

The Content-Security-Policy is Astro's hash-based CSP. Astro lists the SHA-256 hash of every script and style it bundles, so script-src is 'self' plus hashes, with no 'unsafe-inline', and Astro's own island scripts still run. Extra sources are added with CSP_SCRIPT_SRC, CSP_STYLE_SRC, CSP_IMG_SRC, CSP_CONNECT_SRC, CSP_FONT_SRC, CSP_FRAME_SRC, CSP_WORKER_SRC, CSP_CHILD_SRC, CSP_ALLOW_WASM=1 and ANALYTICS_PROVIDERS. The hashes are computed at build time, so these are build-time variables: set them before npm run build. See Security and FRONTEND_EFFECTS.md for loading libraries from a CDN.

CORS and CSRF

  • CORS is an exact-match allow-list in CORS_ORIGINS. Credentials are never allowed. Every /api answer to an allowed origin carries the CORS headers, errors included.
  • CSRF is a double-submit cookie (astrobaas_csrf) checked against the X-CSRF-Token header on cookie-authenticated writes. Bearer requests are exempt. A short list of machine callers (payment-provider webhooks, the consent receipt beacon, one-click unsubscribe) is exempt because each authenticates itself another way. Cookie-less POSTs from an allow-listed origin to the public checkout and form endpoints are also accepted without a token.

Astro's own checkOrigin is turned off because the middleware does this job.

Rate limits

Counters live in memory by default. RATE_LIMIT_STORE=libsql (with a DATABASE_URL) keeps them in the database so several processes share them. The startup log says which is active. Budgets are in Headless and REST API.

Storage

Routes never talk to a database driver. They call LocalDB (src/lib/localdb.ts), which implements the Storage interface (src/core/storage.ts) over one of three drivers, chosen at boot from the environment by src/lib/storage/select-adapter.ts:

Driver Selected by Shape Good for
lowdb JSON no DATABASE_URL (file at DB_PATH, default db.json) one JSON document local development, a small single-process site
libSQL doc-blob DATABASE_URL (file: or libsql://) the same document in one SQLite row a durable single process
relational libSQL DATABASE_URL + DATABASE_DRIVER=relational one table per collection, one row per entity (JSON data column) production, several writers

The smoke suite runs against all three in CI, so a driver that disagrees with the others fails the build. Local file: databases are opened in WAL mode with a 5-second busy timeout and synchronous=NORMAL. On an empty database the schema and the first admin account are created at first boot; there is no separate migration command. Schema migrations run at start-up under a migrations lease, so two processes booting together do not run them twice.

The doc-blob driver rewrites the whole document on every write. Two processes writing to it at once lose each other's changes without an error, so use the relational driver whenever more than one process writes. Full details, including backups under WAL and moving between drivers, are in Backups and storage and STORAGE.md.

In-process caches

AstroBaaS keeps a few small caches in memory. Each one is bounded or expires, so a change made by another process shows up without a restart.

Cache Invalidation
The lowdb document Keyed on the file's inode, size and modification time, so a write by any process (a CLI import, a restore) is seen. The libSQL drivers are not cached this way.
Media base (public_site_url, site_url) Cleared on a local settings write; expires after 5 seconds.
Active theme Expires after 5 seconds.
Plugin registry (active plugins, their routes, content types) Rebuilt locally after a change; other processes notice within 15 seconds.
OG card images The 100 most recent, in memory.
Article view counts Buffered and written on the scheduler's next tick, and on shutdown.

Uploads and the media pipeline

Uploaded files live on local disk in UPLOADS_DIR (default public/uploads) and are served at /uploads/…. In a production build a route serves them, since the adapter's static handler only knows files that existed at build time. Files attached to public form submissions go to PRIVATE_UPLOADS_DIR instead, which nothing serves publicly; staff download them through an authenticated route.

Every file goes through one ingest path (src/lib/media/ingest.ts), whether it came from the admin, the API or an importer:

  1. Type detection from the bytes, never from the extension or the declared type.
  2. Optional virus scan through a ClamAV daemon when MEDIA_SCAN is configured.
  3. SVGs are sanitised and stored as their sanitised form.
  4. Metadata such as EXIF is stripped from images (MEDIA_ORIGINAL_EXIF=keep keeps it on the original).
  5. Derivatives: raster images are re-encoded with sharp into WebP at 400, 800 and 1600 pixels wide, once at upload rather than per request.
  6. Content-addressed write: filenames come from a hash of the content.

The library accepts files up to 10 MB, and video up to 100 MB (MEDIA_MAX_VIDEO_MB). The request body limits are derived from those numbers, and the shipped nginx and Caddy configurations are tested against them.

API responses publish meta.media_base, so a frontend on another host can turn stored /uploads/… paths into absolute URLs. Set the public_site_url setting in production. See Headless and REST API and Content and media.

Uploads are local disk. Several processes on one host can share the directory; processes on different hosts need a shared filesystem.

Content pipeline

Post content is stored as raw HTML. It is sanitised against an allow-list on save (src/lib/sanitize.ts) and rendered through plugin filters on read, so content is the source of truth and content_rendered is derived from it. Because content is sanitised, <script> and <canvas> cannot live inside a post; interactive effects belong in the theme or frontend.

Background jobs: the scheduler

A scheduler (src/lib/scheduler.ts) runs inside the same process, once at start-up and then every SCHEDULER_INTERVAL_MS (default 60 seconds). A sweep:

  • publishes scheduled posts whose time has come;
  • writes buffered view counts;
  • expires payment holds and cancels abandoned unpaid orders;
  • sends back-in-stock notices and newsletter batches;
  • sends recovery reminders;
  • sweeps orphaned private uploads;
  • pushes the off-site backup when one is due.

With more than one process on a database, only the one holding the scheduler lease sweeps. The others stand by and take over within about three intervals if the leader dies, or at the next tick if it shut down cleanly. On the libSQL drivers the lease is a row in a leases table; on lowdb it is a file beside the database. SCHEDULER_DISABLED=1 turns the scheduler off. SCHEDULER_LEASE=0 makes every process sweep, which is only safe with a single process. The operations screen and GET /api/operations show which process leads.

Outbound webhooks are also delivered in the background of the process that fired them, with retries on in-process timers. See Webhooks.

Plugins and themes

Both load at boot, into the same process, with no sandbox. They are trusted code, as appropriate for a self-hosted install.

Plugins (src/plugins/index.ts) come in two tiers:

  • Code plugins: the bundled ones in src/plugins/, plus external packages named in ASTROBAAS_PLUGINS. They can register filters and actions on named hooks, serve their own API routes (through the /api/[...pluginRoute] catch-all) and admin screens, contribute content types and payment providers, and keep data in a namespaced store with their own migrations.
  • Declarative plugins: JSON manifests installed at runtime from the admin. They are stored in the database and compiled to the same plugin shape. No code is executed.

Activation state is stored in the database. ASTROBAAS_PLUGINS_ACTIVATE names plugins to switch on at boot. What loaded, what failed and what is active is reported by the deep health check.

Themes (src/themes/) are the bundled ones plus external packages named in ASTROBAAS_THEMES. A theme replaces named template slots and design tokens and inherits the rest, so a new slot never breaks an existing theme. The active theme is chosen in the admin.

Custom content types registered by plugins or defined in the admin get the generic /api/content/<type> CRUD with the same auth and validation as the built-in types. See Plugins, Themes and Custom content types.

Health endpoints and metrics

Endpoint Access Answers
GET /healthz public Liveness. 200 when the process is up and storage answers a read, otherwise 503. Probes are cached for 2 seconds, so a flood cannot load the database.
GET /readyz public Readiness. 503 while the process is shutting down, when storage is unreachable, or when the database schema is older than this build expects. Point load balancers here.
GET /api/health/deep admin session, or Authorization: Bearer $HEALTH_TOKEN Exercises the things that break quietly and reports each as ok, warn or fail. 503 if anything essential fails, so curl -fsS works in a deploy script.
GET /metrics public when enabled Prometheus text format. Off (404) unless METRICS_ENABLED=1.

The deep check runs these checks: health_token, image_pipeline (sharp actually encodes an image), uploads_writable, public_site_url, cors_origins, database (including a test write; add ?write=0 for frequent polling), rate_limit_store, plugins, media_records, email_channel, payment_return_urls and offsite_backup. Without a HEALTH_TOKEN, only an admin session can read it; anyone else gets 404.

/metrics exposes request counts by status class, 5xx and 429 counters, a latency histogram, in-flight requests, a draining flag and uptime. It carries no content or secrets, but keep it behind your network policy. Per-request logging is controlled by LOG_REQUESTS.

Graceful shutdown

On the first SIGTERM or SIGINT the process:

  1. marks itself draining, so /readyz answers 503 and a balancer stops sending traffic;
  2. stops the scheduler, so no new sweep starts;
  3. waits for in-flight requests to finish, while still serving new ones;
  4. waits for a running sweep to reach its next safe point;
  5. writes buffered view counts;
  6. releases the scheduler lease, so another process takes over at once;
  7. exits 0 with one log line saying what it did.

The whole budget is SHUTDOWN_TIMEOUT_MS (default 25 seconds). It must be shorter than your supervisor's stop timeout: the systemd unit in deploy/ allows 30 seconds and docker-compose.yml sets a 30-second grace period. A second signal exits immediately. GRACEFUL_SHUTDOWN=0 disables all of this.

Running more than one process

The tested shape is one process. More is possible, with these rules:

  • Use DATABASE_DRIVER=relational and a database every process reaches: a file: URL for processes on one host, libsql:// across hosts.
  • Set RATE_LIMIT_STORE=libsql, or each process keeps its own counters and the effective limit multiplies.
  • Uploads are local disk. Processes on different hosts need shared storage for UPLOADS_DIR and PRIVATE_UPLOADS_DIR.
  • Only one process runs the scheduler (the lease holder). Never set SCHEDULER_LEASE=0 with more than one process.
  • Settings, redirects and plugin switches changed through one process reach the others within 15 seconds.
  • Webhook retries live in the memory of the process that fired the event.

See Deployment and deploy/README.md.

Where things live in src/

Path Contents
src/middleware.ts The request pipeline
src/pages/api/ REST endpoints
src/pages/admin/ Admin screens
src/pages/ (rest) Public pages, feeds, /healthz, /readyz, /metrics, /openapi.json, /llms.txt
src/core/ The public contracts: models, Storage, content types, theme slots, manifests (astrobaas/core)
src/lib/ Internals: storage drivers, auth, media, scheduler, webhooks, payments, email
src/client/ The typed client (astrobaas/client)
src/plugins/, src/themes/ Bundled plugins and themes, and their registries

Anything under src/lib/ is internal and may change between releases. Plugins and themes should import from astrobaas/core only.

See also

Clone this wiki locally