Deployment

Romaji to Kana Converter: Astro

Chapter 7 · Deployment

This project has a genuinely different shape from almost every other Astro course on this site: it's a static site by default (Chapter 1's own real, deliberate choice), with exactly one route — Chapter 5's own /api/convert — opted into on-demand rendering. Deployment has to account for both halves of that hybrid at once, and this chapter's own real headline finding is that Astro's build tooling already handles the split cleanly, with no extra configuration needed to stitch the two together.

Building for Production: Two Outputs From One Command

Verified directly against Astro's own current documentation for the @astrojs/node adapter: once an adapter is installed and at least one route opts into on-demand rendering, astro build produces two real, separate outputs from a single command — exactly the split this project's own hybrid setup needs:

npm run build # dist/client/ — every static, prerendered page: index.astro, and any # other page that never opted into on-demand rendering # dist/server/entry.mjs — a real, runnable server handling exactly one # route: /api/convert, per its own export const prerender = false

One Server Handles Both Jobs

A genuinely convenient real detail, confirmed directly against Astro's own documentation: in mode: 'standalone' — already set back in Chapter 5 — "the server handles file serving in addition to the page and API routes." The one built server doesn't just answer /api/convert; it also serves every file sitting in dist/client/ directly, with no separate static file server, CDN, or extra configuration required to wire the two together:

HOST=0.0.0.0 PORT=4321 node ./dist/server/entry.mjs # Serves index.astro's own static HTML AND handles real POST requests # to /api/convert — from the exact same process, on the exact same port

HOST and PORT are read directly by the built server itself — Astro's own documentation is explicit that neither Astro nor the adapter loads environment variables on its own behalf, so anything genuinely secret would need its own loading mechanism; this project has no secrets at all to worry about.

An Honest Contrast With Premier League Predictor: Astro's Own Deployment Chapter
That course's own Chapter 11 runs the identical node ./dist/server/entry.mjs command — but its entire site is genuinely dynamic, so output: 'server' puts every single page through that one Node process. This project runs the exact same real command for a fundamentally different reason: almost the whole site is static and could be served with zero server process at all — the one Node process here exists purely to keep Chapter 5's own real server-side comparison route reachable, not because the site genuinely needs a server to function.

PM2 for Process Management

A single, long-lived process, kept alive and restarted automatically if it ever crashes — there's no shared database, no connection pool, and no multi-writer question anywhere in this project (unlike Premier League Predictor: Astro's own real SQLite-file concurrency question), so there's no real reason to run more than one instance at all:

// ecosystem.config.cjs module.exports = { apps: [{ name: 'romaji-converter', script: './dist/server/entry.mjs', instances: 1, exec_mode: 'fork', env: { HOST: '0.0.0.0', PORT: '4321' }, }], };
pm2 start ecosystem.config.cjs

nginx Reverse Proxy

A standard proxy in front of the one running process — plain proxy_pass, nothing route-specific needed yet, since the same process already correctly serves both static files and the API route on its own:

server { listen 80; server_name romaji.example.com; location / { proxy_pass http://127.0.0.1:4321; proxy_set_header Host $host; } }

A Real Rate Limit — Specifically for /api/convert

Every other route on this project's own site is free, in every real sense — a prerendered static file costs the server nothing at all to serve beyond a plain file read. Chapter 5's own real, measured finding changes that story for exactly one route: each real request to /api/convert costs the server roughly 14 milliseconds of genuine processing time, compared to Chapter 4's own client-side box, which costs the server nothing whatsoever. That specific route — and only that one — is genuinely worth protecting from a real flood of requests:

# nginx.conf, inside the http {} block limit_req_zone $binary_remote_addr zone=convert:10m rate=10r/s; limit_req_status 429; // real 503-by-default override — 429 Too Many Requests is the more accurate code here server { /* ...listen/server_name as above... */ location /api/convert { limit_req zone=convert burst=20 nodelay; proxy_pass http://127.0.0.1:4321; proxy_set_header Host $host; } location / { proxy_pass http://127.0.0.1:4321; proxy_set_header Host $host; } }
What Each Real Directive Actually Does
rate=10r/s allows a sustained average of 10 requests per second from any one client IP (tracked via $binary_remote_addr); burst=20 nodelay lets a genuine short spike of up to 20 requests through immediately rather than queuing them, with anything past that rejected outright rather than delayed. limit_req_status 429 overrides nginx's own real default of 503 — a deliberate choice here, since 429 Too Many Requests is the more semantically accurate real HTTP status for "you're sending requests too fast," verified directly against nginx's own documentation.

No equivalent limit is placed on the static routes at all — there's no real cost to protect there, and adding one would just be friction with nothing genuine behind it.

A Real, Honest Closing Reflection

This entire chapter exists to keep one specific route reachable and correctly deployed — a route Chapter 5's own real, measured evidence already concluded shouldn't be this app's own default. That's worth stating plainly rather than glossing over: if this project were being deployed purely as a production feature rather than as a working, comparable example for this course, the honest, evidence-based choice would be to drop /api/convert, the Node adapter, and this entire chapter's own PM2/rate-limiting setup altogether — deploying as nothing more than a folder of static files with zero server process required at all. This chapter builds out the fuller deployment anyway, specifically because the server-side comparison route is worth keeping reachable for anyone reading this course to try both approaches for real, not because the finished app actually needs it.

Hands-On Exercises

Exercise 1

Build the project, run the standalone server, and confirm — with a browser and with a real curl POST request — that both index.astro's own static page and the on-demand /api/convert route are served by the exact same running process on the exact same port, with no separate static file server involved.

📄 View solution
Exercise 2

Add the nginx rate-limit configuration exactly as this chapter describes, then send a real rapid burst of more than 20 requests to /api/convert from a script. Confirm some requests succeed and later ones are rejected with a real 429 status, matching the limit_req_status override.

📄 View solution
Exercise 3

Given Chapter 5's own real, measured verdict that client-side conversion is objectively better for this feature, explain in your own words why this chapter still builds out a full deployment story for the server-side route rather than simply recommending it be deleted.

📄 View solution

Chapter 7 Quick Reference

  • Two outputs, one build — dist/client/ (static pages) and dist/server/entry.mjs (the one on-demand route), verified against Astro's own current documentation
  • One process, two jobs — standalone mode's built-in file serving means the same server handles both static files and /api/convert, no extra config needed
  • PM2, single fork instance — no database, no multi-writer question, no real reason to run more than one
  • nginx rate limit on /api/convert only — real, targeted protection for the one route with a genuine per-request server cost (Chapter 5's own measured ~14ms), left off every free static route
  • 429, not 503 — a deliberate limit_req_status override to the more semantically accurate real HTTP status code
  • The honest reflection — this whole chapter deploys a route Chapter 5's own evidence already said shouldn't be the default, kept alive specifically so it stays reachable for comparison
  • Next chapter: Capstone — shipping this as a real new page on the live site, and scoping kanji conversion as a genuine future sub-project