Deployment

Premier League Predictor: Astro

Chapter 11 · Deployment

Chapter 1's own real payoff shows up fully here: there's no separate backend framework to deploy alongside the site, since Astro's own server endpoints already are the backend. What's actually needed is building the project, running the resulting server as a real, long-lived process, and putting a reverse proxy in front of it — plus two real questions specific to this stack that neither sibling course had to answer the same way: what a single-file SQLite database does and doesn't need at deploy time, and what happens to a native Node module when it's built on one machine and run on another.

Building for Production: A Standalone Node Server

Checked directly against Astro's own documentation for the @astrojs/node adapter: mode: 'standalone', already set back in Chapter 1, "builds a server that automatically starts when the entry module is run," specifically so there's no need to write any wrapping Express or Fastify code — a genuine, real contrast against mode: 'middleware', which produces a request handler meant to be mounted inside a separate Node server you'd have to write yourself.

npm run build # produces ./dist/server/entry.mjs — a real, complete, runnable server HOST=0.0.0.0 PORT=4321 node ./dist/server/entry.mjs

HOST and PORT are read directly by the built server itself — real, documented environment variables, not something this app's own code has to parse or wire up.

A Real Native-Module Gotcha: better-sqlite3 Isn't Portable JavaScript

better-sqlite3 isn't pure JavaScript — it's a real native Node addon, compiled C++ bindings against SQLite's own C library, built specifically for the exact combination of Node version, operating system, and CPU architecture it's installed on. A node_modules folder built on a developer's own Windows machine and copied straight onto a Linux server won't necessarily run there at all; the compiled binary simply doesn't match the target platform.

Run npm install/npm ci on the deployment target itself, not the developer's own machine
The safe, standard fix is the same one any project with native dependencies needs: run npm ci (or npm install) as part of the actual deployment process, on the actual machine — or the actual container image — the app will run on, so the native binary gets compiled (or fetched as a prebuilt binary matching that exact platform) for the real target, rather than trusting a copy of node_modules built somewhere else entirely.

No Connection Pool to Size — A Different Question Entirely

Both sibling courses' own Chapter 11s center on a real connection-pool-sizing problem: the FastAPI & PostgreSQL course found 4 gunicorn workers times SQLAlchemy's own default pool settings can open up to 60 real database connections; the Django & MySQL course found the opposite structural problem, a fresh connection on every single request under Django's own CONN_MAX_AGE=0 default. Neither question exists here at all. better-sqlite3 doesn't open a network connection to a separate database server — it opens the local pl_predictor.db file directly, once, in db.ts. There's no pool, no connection limit on a remote server, and no per-request connection overhead to reason about, because there's no separate server process being connected to in the first place.

The real question this stack faces instead: multiple Node processes, one shared file
If this app were ever run as more than one Node process at once — a process manager configured for several worker instances, for genuine throughput reasons this personal tool almost certainly doesn't have — each process would open its own, separate connection to the exact same on-disk file. That's a genuinely different kind of concurrency question than either sibling course faced: not "how many network connections can the database server handle," but "what happens when more than one process tries to read or write the same local file at once."

Running a Single Process Is the Right Call Here

Given this app's own real, honestly-scoped audience — one admin managing a personal prediction tracker, not a multi-tenant service under real concurrent load — the simplest, correct answer is to just not create the multi-process question at all:

# ecosystem.config.cjs — PM2 keeps the one process alive and restarts it on crash module.exports = { apps: [{ name: 'pl-predictor', script: './dist/server/entry.mjs', instances: 1, // deliberately fork mode, not cluster mode exec_mode: 'fork', env: { HOST: '0.0.0.0', PORT: '4321' }, }], };

pm2 start ecosystem.config.cjs keeps this one process running and restarts it automatically if it ever crashes — real, practical process management, without ever needing to answer a multi-writer question this app doesn't actually have.

WAL Mode & busy_timeout: The Real Fix, If More Than One Process Ever Writes

Even at a single process, it's worth setting this correctly, since it costs nothing and directly protects against the one genuinely real scenario this app can hit on its own: an admin request writing a result (Chapter 6) at the same moment a browser tab is reading the league table (Chapter 7). SQLite's default rollback-journal mode has a writer block every reader until it finishes; a documented, better alternative exists:

// src/lib/db.ts (addition, right after opening the connection) db.pragma('journal_mode = WAL'); db.pragma('busy_timeout = 5000');
Verified directly against SQLite's own documentation: what WAL actually fixes, and what it doesn't
"WAL provides more concurrency as readers do not block writers and a writer does not block readers. Reading and writing can proceed concurrently." That's a genuine, real fix for the read-while-writing scenario above. It's not, however, a fix for two simultaneous writers — SQLite's own documentation is explicit that WAL mode can still return SQLITE_BUSY in real, if rarer, cases, so busy_timeout is set alongside it specifically to make a connection retry quietly for up to 5 seconds before giving up, rather than surfacing a real error the instant two writes land in the same instant.
A real, honest scaling boundary: WAL only works on a local filesystem
SQLite's own documentation states plainly that "all processes using a database must be on the same host computer; WAL does not work over a network filesystem," since it depends on shared memory between processes on the same machine. That rules out one specific future direction cleanly: this app could never be horizontally scaled across multiple separate servers sharing one SQLite file over a network mount. That's an honest, real limit of the single-file-database choice made all the way back in Chapter 1 — not a WAL-specific problem, but a genuine consequence of it that's worth being explicit about now rather than discovering it while trying to scale later.

Backing Up a Single File — Correctly

Neither sibling course's own database can be backed up by simply copying a file while the server is running — PostgreSQL and MySQL both need a dedicated export tool (pg_dump, mysqldump) precisely because their data lives across many files a live server is actively writing to. SQLite's entire database really is one file, which makes a backup genuinely simpler — with one real caveat worth getting right:

# The safe way: SQLite's own online backup command, not a raw file copy sqlite3 data/pl_predictor.db ".backup data/backups/pl_predictor-$(date +%F).db"
A plain cp while the server is running can copy a database mid-write
A raw cp data/pl_predictor.db backup.db reads the file byte-for-byte with no awareness of whether a write is in progress — under WAL mode specifically, recent commits can still be sitting in the separate -wal file rather than the main database file yet, so a naive copy of just the main file can miss them entirely. SQLite's own .backup command is built specifically to produce a correct, consistent snapshot of a live, in-use database, handling exactly this case properly.

Closing the Loop: Chapter 3's Own Flagged Security Gap

Chapter 3 was explicit about a real, deliberate gap: "there's no login, no admin check, nothing gating who can add or remove a team... the real, honest question of whether this app is ever meant to be used by more than one person is still genuinely open." Deployment is exactly where that stops being an academic question — once this app is reachable on a real, public address, every admin route really is reachable by anyone who finds it. Building genuine application-level authentication was never in this course's own scope, but a real, pragmatic fix exists at the deployment layer instead:

# nginx: gate the admin surface with real HTTP Basic Auth # (one real username/password pair, created via: htpasswd -c /etc/nginx/.htpasswd admin) location /admin/ { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; proxy_pass http://127.0.0.1:4321; } location / { proxy_pass http://127.0.0.1:4321; }
A pragmatic fix for a personal, single-operator tool — not a general answer
HTTP Basic Auth gates every request under /admin/ — including this course's own admin-only API routes, since they all live under that same path — behind one shared username and password, checked by nginx before the request ever reaches the Node process at all. That's a genuinely appropriate, low-effort fix for exactly the scope this app was always built for: one real admin, not a multi-user system with individual accounts, roles, or permissions. If this app were ever meant for more than one person, that would be real, new scope calling for genuine application-level authentication — not something a reverse-proxy password prompt could honestly stand in for.

Where This Course Is Headed

The capstone — mounting this predictor directly onto the real, live Astro site it was always meant to sit alongside (Chapter 12).

Hands-On Exercises

Exercise 1

Explain why neither sibling course's own connection-pool-sizing question (workers × pool size, or CONN_MAX_AGE) has any real equivalent in this course, and describe the genuinely different concurrency question that replaces it instead.

📄 View solution
Exercise 2

Explain precisely what WAL mode fixes and what it doesn't, using SQLite's own documented distinction between reader/writer concurrency and writer/writer concurrency, and explain why busy_timeout is still set even with WAL enabled.

📄 View solution
Exercise 3

Build the project, run it as a standalone Node server behind an nginx reverse proxy with Basic Auth gating /admin/, and confirm directly: an unauthenticated request to a real admin API route under /admin/ is rejected by nginx before it ever reaches the Node process, while a request to the public league table route succeeds with no credentials at all.

📄 View solution

Chapter 11 Quick Reference

  • npm run build — produces a real, runnable standalone server at ./dist/server/entry.mjs, per @astrojs/node's own documented standalone mode
  • HOST/PORT — real environment variables read directly by the built server, no extra wiring needed
  • better-sqlite3 is a native module — run npm ci on the actual deployment target, never copy node_modules from a different OS/architecture
  • No connection pool exists — a direct, structural contrast with both sibling courses' own Chapter 11 headline findings; there's no network database server to open a pool of connections to
  • A single Node process, PM2 fork mode — the honest right choice for this app's own real, personal-scale audience, sidestepping the multi-writer question entirely
  • journal_mode = WAL + busy_timeout = 5000 — fixes readers-blocked-by-a-writer (verified against SQLite's own docs), but not writer-vs-writer contention, and doesn't work over a network filesystem
  • sqlite3 ... .backup — the real, safe way to back up a live SQLite database; a plain cp can miss recent WAL-mode commits
  • nginx auth_basic on /admin/ — a pragmatic, deployment-level close of Chapter 3's own flagged "no authentication" gap, appropriate specifically because this app was always scoped to one real admin
  • Next chapter: Capstone — mounting the predictor onto the real, live Astro site