🔌 Add a backend only when forced
The few things a browser can't do, and the smallest server piece for each: a Caddy route, a zero-dependency Node container, or a real framework.
You'll leave with
- A clear test for when your app truly needs a server
- A CORS proxy with zero code, written as one Caddy route
- A tiny Node API behind /api with its data outside the web root
Every app so far has been files: the server hands them over and the visitor's browser does the work. This lesson covers the few jobs a browser can't do, and the smallest server piece for each: a few lines in your front door's config (Caddy, from Deploy), a tiny Node server (JavaScript running on your server) with no packages, or, rarely, a real framework. We climb only when forced.
What are a request, a response and a backend?
A browser sends a request ("give me /api/summary") and gets a response: a status code (200 worked, 404 not found, 502 the program behind the front door didn't answer), headers (labeled lines of extra information) and the content. A backend is a program on your server that builds responses itself instead of handing over a file.
When the browser isn't enough
Anything inside a page, the person holding the browser can read and change. So the test is five questions, about secrets, limits, CORS, rate limits and size. One yes means the browser alone can't do the job.
What are an API, an origin and CORS?
An API is a web address that returns data (often JSON, a plain-text data format), not a page; some want a key, a secret code identifying your app. An origin is https:// plus a site's full name. CORS is the browser rule that lets a page read another origin's answer only if that site sends an Access-Control-Allow-Origin header allowing it.
What's a rate limit?
A cap on how many requests a service accepts per minute, hour or day, counted per key or per IP address (the number identifying a computer on the internet). Go over it and you get errors, or a block.
| You need | Why the browser can't | Smallest fix |
|---|---|---|
| A secret key | Anyone can read the page's code | Your server makes the call (step 3) |
| A limit people can't cheat (a daily cap, who paid) | Visitors can reset any counter in their browser | A server keeps the count, as for Acento's daily free limit |
| An API with no CORS header | The browser hides the answer from your page | A front-door route (step 2) |
| A source that rate-limits every visitor | Busy visitors hit the cap | Your server fetches on a timer and serves its copy (step 3) |
| Data too big to ship | Every visitor downloads all of it | A server that answers questions about it (the framework section) |
Reminders and email lists count too: their sender holds a secret and runs on step 3's server while nobody has your page open (Build an email list and Make it an app on their phone add it).
"It feels slow" is not on the list. Measure.
📍 My river-flow side project first fetched about 130 government river gauges straight from the browser: about 31 seconds and 186 KB in a headless test (a browser with no window, run by a script). Headless tests exaggerate (see Check the AI's work), but Node fetched the same data in about 1 second. That gap is an answer. Now a small server fetches every 10 minutes and serves a 7 KB summary in about 0.4 seconds, and the page falls back to the source if /api is down.
Get your own two numbers. On your live page in Chrome or Edge, press F12 (Mac: Cmd+Option+I) for DevTools, click Console, and paste this with your source's domain in place of api.example.gov. If Chrome blocks the paste, type allow pasting, press Enter and paste again. Safari needs developer features on (Settings › Advanced); phones can't do this.
const r = performance.getEntriesByType('resource').filter(e => e.name.includes('api.example.gov'));
console.log(r.length + ' requests, last one done ' + (Math.max(...r.map(e => e.responseEnd)) / 1000).toFixed(1) + ' s after the page started');
Then Node, in VS Code's terminal (Git Bash or PowerShell). Replace the example address with one your page requests (r.map(e => e.name) in the Console lists them):
node -e "const t = Date.now(); fetch('https://api.example.gov/data?sites=1,2,3').then(r => r.text()).then(b => console.log(b.length, 'characters in', Date.now() - t, 'ms'))"
A page that makes many requests needs them timed together. Hand that to Claude:
EXAMPLE — YOU ASK CLAUDE CODE
node time-sources.mjs.↳ Read · app.js↳ Write · time-sources.mjs↳ Bash · node time-sources.mjs ×3What's happening underneath: Claude finds the addresses in your code and fetches them from Node. Look for a big gap, like 31 seconds against 1, or dozens of requests per visit. Three requests in a third of a second isn't one: write "stay static" in NOTES.md unless another row says yes.
Zero code first: a Caddy route
Say your page has an address search, and the free U.S. Census geocoder (address in, coordinates out) is the right source. It sends no CORS header, so a page calling it directly gets a red blocked by CORS policy error in the Console.
No backend needed. Caddy, the front door from Deploy behind one front door (it hands each request to the right container, the sealed box an app runs in), can fetch it for you: the page asks /geocode on your own domain, and Caddy asks the Census. To the browser that's one site, so CORS never comes up.
📍 Ticket Heat's address search works exactly like this. Between them, Ticket Heat and Habooby reach three outside sources this way (addresses, rain gauges, pollen), with no new container and no server code.
Type /add-dir ../infra in the Claude Code box first, as in Deploy, so Claude can edit the master Caddyfile.
EXAMPLE — YOU ASK CLAUDE CODE
/geocode with 200 W Washington St, Phoenix, AZ 85003 returns matchedAddress 200 W WASHINGTON ST, PHOENIX, AZ, 85003, and /.env returns 404.↳ Edit · infra/Caddyfile↳ Bash · git commit · scp · caddy validate · caddy reload↳ Bash · curl ×2What's happening underneath: handle blocks now sort requests by path, and the last one, with no path, still sends everything else to your files. Your block from Deploy, after the change:
myapp.yourdomain.com {
encode zstd gzip
header Cache-Control "no-cache"
@private path /.git/* /.env* *.env *.bak *.old *.zip *.tgz *.sql
handle @private {
respond 404
}
handle /geocode {
rewrite * /geocoder/locations/onelineaddress?{query}&benchmark=Public_AR_Current&format=json
reverse_proxy https://geocoding.geo.census.gov {
header_up Host geocoding.geo.census.gov
}
}
handle {
reverse_proxy myapp:80
}
}
handle /geocode catches that exact path. rewrite swaps in the geocoder's real path and adds the two settings it needs, keeping the visitor's address ({query} is everything after the ?). reverse_proxy makes Caddy itself ask, and header_up Host relabels the request:
Why set the Host header?
Every request names its site in a Host header, since one server often hosts many. Caddy before version 2.11 passed on your domain, which can earn an error or a 404; newer Caddy sends the source's own name for https:// addresses. The line keeps it explicit on any version.
⚠️ Note the @private change. Caddy runs a block's lines in its own fixed order, and respond comes after handle, so once a catch-all handle exists, a bare respond @private 404 never runs and /.env answers 200 again. In its own handle, it works.
Ship it as in Deploy (the prompt covers it), then run this in the Console on your live site:
fetch('/geocode?address=' + encodeURIComponent('200 W Washington St, Phoenix, AZ 85003')).then(r => r.json()).then(j => console.log(j.result.addressMatches[0].coordinates))
Coordinates, no CORS error, longitude (x) first. And the private rule still bites:
curl -s -o /dev/null -w '%{http_code}\n' https://myapp.yourdomain.com/.env
Three rules for every route like this
- Only the headers the source documents. If its docs ask for a User-Agent naming your app (the National Weather Service's do), add
header_up User-Agent "myapp (you@yourdomain.com)"inside thereverse_proxybraces; a 403 or 405 often means a missing one. Never disguise your server as a browser. - Read its terms first. Some forbid proxying or bulk use.
- Cache. Every visitor now arrives from your server's one IP address, so the source sees one very busy user (a 429, Too Many Requests, is the warning). Caddy keeps no copies without a plugin, so the page asks only on Search, never per keystroke, and remembers answers:
const seen = new Map(); // answers we already have
async function geocode(q) {
if (!seen.has(q)) {
const r = await fetch('/geocode?address=' + encodeURIComponent(q));
const m = (await r.json()).result.addressMatches[0];
seen.set(q, m ? { lat: m.coordinates.y, lon: m.coordinates.x } : null);
}
return seen.get(q);
}
It returns {lat, lon}, or null when nothing matches.
Same-origin /api
Your own backend works like step 2: /api/* goes to its container, everything else to your files. One origin means no CORS settings and no fights over cookies (small values a site stores in your browser). The page calls fetch('/api/summary'), no domain needed.
browser ──▶ myapp.yourdomain.com caddy, the front door
├─ /geocode ──▶ the Census geocoder, outside, over HTTPS
├─ /api/* ──▶ myapp-api your Node server (step 3)
└─ the rest ──▶ myapp your files, as before
📍 Acento's page calls /api/... on its own domain. That route first went to Acento's own PocketBase container (a one-file backend with logins and a database). When I moved the fleet to one shared accounts server, I changed that one proxy line; the page never noticed, and the old container was retired.
Two words decide what the backend sees. handle passes the path on as asked; handle_path strips the prefix it matched:
| Caddy line | The page asks for | The backend receives |
|---|---|---|
handle /api/* | /api/summary | /api/summary |
handle_path /tools/* | /tools/ping | /ping |
Use handle for your own server, whose routes include /api. handle_path suits a program that expects paths from the root, but it forwards everything under the prefix, admin pages included. Two backends, two prefixes: this course's site uses /api for its own server and /accounts for the shared one. Accounts without building auth (next) forwards only /accounts/api/* to it, so no admin pages answer on your domain.
Check what's actually running before you edit
📍 Partway through an Acento feature, I was editing its own backend's code, then found that container had been stopped for over four weeks: every /api route had moved to the shared server. The old folder still looked like the real thing. The work moved to the right file, and Acento's notes now say in bold which backend is live.
So ask the server, not your memory (try these after step 3):
ssh root@YOUR_VPS_IP "grep -n -A20 'myapp.yourdomain.com' /root/caddy/Caddyfile"
ssh root@YOUR_VPS_IP "docker ps -a --filter name=myapp --format '{{.Names}} {{.Status}}'"
curl -s https://myapp.yourdomain.com/api/health
The first shows which container each handle sends to. The second should say Up (Exited (0) 4 weeks ago means nothing there is live). The third prints your server's health answer, starting {"ok":true. Note the answer in NOTES.md (/api = myapp-api), and put RETIRED atop any old backend folder's README.
A tiny Node server with zero dependencies
Now the backend at its smallest: one server.js using only modules that come with Node. No npm install, no Dockerfile, no build, so a deploy has nothing to break: updating is copying one file and restarting. This course's own server is built exactly this way.
What are dependencies and built-in modules?
A dependency is someone else's code your program needs, usually installed with npm, Node's package installer; each can change or break on an update. Built-in modules like http (a web server) and fs (files), plus fetch, ship inside Node.
Build it even if step 1 said "stay static": the stand-in source (three National Weather Service stations) needs no key, and reminders or an email list will need this server. Keep it in an api folder, which the file list you ship in Deploy leaves out.
EXAMPLE — YOU ASK CLAUDE CODE
/api/summary returned JSON with 3 items, /api/health returned ok, and /api/../server.js returned 404.↳ Write · api/server.js↳ Bash · node api/server.js · curl ×3What's happening underneath, to check Claude's version against:
// server.js: a tiny API with zero dependencies (Node's built-in modules only)
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const PORT = Number(process.env.PORT) || 3000;
const STATE = path.join(process.env.DATA_DIR || '/data', 'state.json');
const CONTACT = process.env.CONTACT || 'you@yourdomain.com';
// Your source goes here: what to fetch, and the few fields worth keeping.
const STATIONS = ['KPHX', 'KTUS', 'KFLG'];
async function fetchOne(id) {
const r = await fetch(`https://api.weather.gov/stations/${id}/observations/latest`, {
headers: { 'User-Agent': `myapp (${CONTACT})` },
signal: AbortSignal.timeout(15000),
});
if (!r.ok) throw new Error(`${id}: HTTP ${r.status}`);
const p = (await r.json()).properties;
return { id, temp_c: p.temperature.value, sky: p.textDescription, at: p.timestamp };
}
let state = null;
try { state = JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch {} // last good copy, if any
async function refresh() {
try {
const items = await Promise.all(STATIONS.map(fetchOne));
const next = { updated: new Date().toISOString(), items };
fs.writeFileSync(STATE + '.tmp', JSON.stringify(next));
fs.renameSync(STATE + '.tmp', STATE); // swap in the whole file, never a half-written one
state = next;
console.log('refreshed', items.length, 'items');
} catch (e) {
console.error('refresh failed, keeping the last good copy:', e.message);
}
}
function send(res, code, body) {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
// The allowlist: only these exact paths answer. Nothing turns a URL into a file path.
const ROUTES = {
'/api/summary': res => state ? send(res, 200, state) : send(res, 503, { error: 'warming up' }),
'/api/health': res => send(res, 200, { ok: true, updated: state && state.updated }),
};
http.createServer((req, res) => {
const route = ROUTES[req.url.split('?')[0]];
route ? route(res) : send(res, 404, { error: 'not found' });
}).listen(PORT, '0.0.0.0', () => console.log('listening on', PORT));
refresh();
setInterval(refresh, 10 * 60 * 1000); // every 10 minutes
- Refreshes at start, then every 10 minutes: three requests each time, however many people visit. A failed refresh keeps the last good copy, which also survives restarts in
state.json. - Answers only the paths in
ROUTES, an allowlist (a fixed list of what's permitted). - Listens on 0.0.0.0, every address in the container. On
localhostonly, the front door gets 502. - A key for your source goes in the env file, read as
process.env.SOURCE_KEY, and never reaches the browser. After editing the env file, recreate the container (docker rm -f myapp-api, then the samedocker run); a restart keeps old values.
What's an env file?
A text file of NAME=value lines. Docker hands each to the program as an environment variable, which Node reads as process.env.NAME. Secrets live there, never in code or the web folder.
🦥 Skipped: a database, packages, a Dockerfile, a framework. A JSON file in /data is plenty here. Add a database when many people write at once (the next lesson's accounts server has one).
Three folders, one writable
| Folder | Holds | Mounted in |
|---|---|---|
/root/myapp | Your site's files, from Deploy | myapp, read-only |
/root/myapp-api | server.js | myapp-api at /app, read-only |
/root/myapp-data | state.json | myapp-api at /data, writable |
🛑 Never nest the data folder inside /root/myapp. The file server publishes everything there, so a data/accounts.json is downloadable by anyone who guesses the name, and a deploy can overwrite it.
EXAMPLE — YOU ASK CLAUDE CODE
refreshed 3 items. Publicly, /api/summary returns 200 with 3 items, and /data/state.json and /server.js return 404.↳ Bash · scp · ssh root@YOUR_VPS_IP · docker run · docker logs↳ Edit · infra/Caddyfile↳ Bash · caddy validate · caddy reload · curl ×3What's happening underneath:
mkdir -p /root/myapp-api /root/myapp-data /root/secrets && chmod 700 /root/secrets
echo 'CONTACT=you@yourdomain.com' >> /root/secrets/myapp.env && chmod 600 /root/secrets/myapp.env
scp api/server.js root@YOUR_VPS_IP:/root/myapp-api/
docker run -d --name myapp-api --restart unless-stopped --network web \
-v /root/myapp-api:/app:ro -v /root/myapp-data:/data -w /app \
--env-file /root/secrets/myapp.env \
node:24-alpine node server.js
sleep 3 # give Node a moment to start and fetch
docker logs myapp-api
docker exec caddy wget -qO- http://myapp-api:3000/api/health
node:24-alpine is the stock image: Node 24, a long-term-support version, on Alpine, a tiny Linux. :ro makes the code read-only; /data is the one writable place. With no -p, only the front door can reach port 3000, and the last line asks from inside it, as every visit will.
Then one more handle, for /api/*, shipped like step 2's. The finished block:
myapp.yourdomain.com {
encode zstd gzip
header Cache-Control "no-cache"
@private path /.git/* /.env* *.env *.bak *.old *.zip *.tgz *.sql
handle @private {
respond 404
}
handle /geocode {
rewrite * /geocoder/locations/onelineaddress?{query}&benchmark=Public_AR_Current&format=json
reverse_proxy https://geocoding.geo.census.gov {
header_up Host geocoding.geo.census.gov
}
}
handle /api/* {
reverse_proxy myapp-api:3000
}
handle {
reverse_proxy myapp:80
}
}
Skipped step 2? Leave out handle /geocode. Now prove it from outside:
curl -s https://myapp.yourdomain.com/api/summary
curl -s -o /dev/null -w '%{http_code}\n' https://myapp.yourdomain.com/data/state.json
curl -s -o /dev/null -w '%{http_code}\n' https://myapp.yourdomain.com/server.js
curl -s -o /dev/null -w '%{http_code}\n' --path-as-is https://myapp.yourdomain.com/api/../server.js
Your summary, with today's numbers, then three 404s: the data, the code and the path trick (.. means "up one folder") reach nothing.
After every edit, copy server.js up and restart that one container: Node reads its code once, at start. Static files never need this.
scp api/server.js root@YOUR_VPS_IP:/root/myapp-api/
ssh root@YOUR_VPS_IP docker restart myapp-api
| You see | It means | Do this |
|---|---|---|
502 from /api/summary | Caddy can't reach the container | docker logs myapp-api; check it's on web and listens on 0.0.0.0 |
503, and refresh failed in the log | The fetch or the save failed; the log line says which | EROFS or ENOENT means a write outside /data, or no /data mount |
name "/myapp-api" is already in use | You ran docker run twice | docker rm -f myapp-api, then the same docker run |
Pages come from an allowlist too
Serving files, like members-only pages? Look names up in a map; never build a path from the URL. This course's server serves only page names (slugs, the short names in a web address) listed in its map, and sends unknown ones to the course home. Try the path trick on it:
curl -s -o /dev/null -w '%{http_code}\n' --path-as-is https://ship.traqqit.com/members/../server.js
Answer from your own cache
Your server now answers everyone from its saved copy. Keep it that way: opening a page should cost the source nothing, so nobody gets you rate-limited by clicking.
📍 On my river-flow side project, a watcher checks a federal permit site on a schedule, and the calendar's booked-out dates come from its saved state: opening a river's page sends the permit site zero requests.
Keep the old direct path as a fallback for when /api is down (it needs a source that sends CORS headers; the weather service does):
async function loadSummary() {
try {
const r = await fetch('/api/summary');
if (!r.ok) throw new Error('api ' + r.status);
return await r.json();
} catch (err) {
return loadFromSource(); // your page's old direct code, returning the same shape
}
}
The response shape is a promise
Once anything else reads your endpoints (another app, a script, an AI agent), each response's shape is a promise: add fields and query parameters, never move, rename or remove one. Adding wind_kmh is fine; renaming temp_c breaks every reader, silently.
📍 An AI agent reads a private dashboard of mine through its compact endpoints, and can only fetch small responses. A redesign put the new data only in the full endpoint and behind a new query parameter, so the agent's calls came back exactly as before.
Save one real response per endpoint as a fixture (a saved sample answer), and make your self-check from Check the AI's work fail if a field goes missing.
When you need a real framework
A framework, a ready-made skeleton for a bigger server, earns its place with many server-built pages, heavy queries, or data too big for a browser, like my price tool's source files (hundreds of megabytes to tens of gigabytes). It runs FastAPI and Jinja over DuckDB and Parquet.
What are FastAPI, Jinja, DuckDB and Parquet?
FastAPI is a Python web framework. Jinja fills HTML templates with data on the server. DuckDB is a database that runs inside your app and queries files directly. Parquet is a compressed file format for big tables.
Habits that carry over:
- Boot first, load data later. The server starts even with no data ("no data for this area yet"), and the big import runs separately, because at startup one broken hospital link would trap the container in a crash-and-restart loop. Data files are read per request, so new data went live without a restart.
- Fetch on demand. The home page renders only the search box, and each procedure added fetches its own result card (
/card?code=...), so one service or ten costs the same first load. - One image, two products. A private build runs the same image with
APP_CONFIG=config-b, pointing at its own lists, markets and branding. Two slips (both sites reporting to one analytics site, and a shared link-preview image in the wrong brand's colors) taught me each product needs its own. - Optional AI, gated on the key. The negotiation coach and bill-photo reader say "not enabled" without their API key, and the free features never need them. At a few cents a call, input caps and a per-IP rate limit came first; both shipped switched off.
- Fast checks. Importing the whole app took 85 to 200 seconds on my Windows laptop, so a small harness renders only the templates with sample data, in seconds; the full test runs before a deploy.
⚠️ A Jinja trap from that private variant: x.values finds the dictionary's built-in method before your data, so a key named values (or items, or keys) prints something like "built-in method values of dict object". Rename the key, or write x['values'].
Exotic but handy (advanced)
Skip this on a first read: three patterns I've each used once.
Supabase's API without Supabase
Supabase's data API is an open-source program, PostgREST, so an app written for Supabase can run on your own PostgREST container over the Postgres you already run: a new database plus Supabase's three roles. anon reads what row-level security allows, service_role bypasses it for server jobs, and authenticator is the login PostgREST switches from. The anon and service keys are JWTs signed with PostgREST's secret; any other secret gets 401.
What are Postgres, Supabase, PostgREST, a JWT and row-level security?
Postgres is a popular open-source database server; Supabase is a hosted service built on it, and PostgREST turns its tables into web addresses. A JWT is a signed token: anyone can read it, only the secret's holder can make one. Row-level security is Postgres rules about which rows each role sees.
On an unlisted public-health map of mine, the data API isn't on the internet at all. The Supabase library calls /rest/v1/... but PostgREST serves from the root, so a plain-port block strips the prefix:
:8081 {
handle_path /rest/v1/* {
reverse_proxy myapp-rest:3000
}
}
The front door publishes only 80 and 443, so 8081 answers only containers on web, and server-side code uses SUPABASE_URL=http://caddy:8081. Proof: on the server, curl -m 3 http://localhost:8081/ must fail to connect.
Phones as game controllers
For an unlisted browser party game, phones became controllers in a day: the big screen's browser runs the game, and phones join by room code and send button presses through a tiny Node relay that only forwards messages. It's this lesson's one package (ws), since Node's built-in WebSocket can't accept connections. The host clamps every value phones send.
What's a WebSocket?
A connection that stays open, so server and browser can push messages to each other instantly. wss:// is its encrypted form, like https://.
handle /ws {
reverse_proxy myapp-ws:8080
}
Caddy passes WebSockets through on its own. Phones connect to wss://myapp.yourdomain.com/ws, since an https page can't open a plain ws:// connection.
Do it with Claude: the prompts
The three prompts, in order; check each the way its step shows before the next. Replace YOUR_API_URL with the API your page can't call.
Add a Caddy route so /geocode on my domain proxies to YOUR_API_URL with the Host header set. No new container.
Write server.js using only Node built-ins: GET /api/summary returns /data/state.json, refreshed every 10 minutes.
Run it in node:24-alpine with the code read-only and /data writable, and route /api/* to it in my Caddy block.
What's happening underneath: the browser only ever talks to your domain, and Caddy decides which container answers each path. Push back when Claude's version builds a file path from the URL, writes anywhere but /data, adds packages or a Dockerfile, publishes a port with -p, listens on localhost only, or leaves respond @private outside a handle.
Your turn
- Run the five-question test and both timings on your app. Done when NOTES.md holds both numbers (or "no outside requests") and one verdict: stay static, a front-door route, or a small server.
- Ship the
/geocoderoute, or one to the no-CORS API your app needs. Done when the Console check prints coordinates with no CORS error and/.envstill returns 404. - Run
myapp-apionnode:24-alpinebehind/api. Done when/api/summaryreturns JSON publicly, and/data/state.json,/server.jsand the--path-as-istrick all return 404. - Add
wind_kmh: p.windSpeed.valueto the objectfetchOnereturns, copyserver.jsup and restartmyapp-api. Done when the new field appears and every old field is unchanged. - Run the three what's-running checks. Done when both containers say
Upand NOTES.md names the container behind/api.
Next, Accounts without building auth: logins and saved progress from a ready-made accounts server, behind the same front door.