Deploying a Budget-Friendly TypeScript Full‑Stack with Postgres, JWT Auth, and LLM‑Automated Monitoring
- Keyword
- TypeScript full-stack deployment
- Length
- 2904 words
- Read
- 13 min
I built a small TypeScript API + Astro front‑end that runs on Render’s free tier, uses Neon Postgres for data, and protects routes with a simple JWT cookie. The monitoring part is a 5‑minute script generated by an LLM and run as a Cloudflare Worker; it pings the health endpoint, checks DB latency, and sends a Slack webhook if anything looks off.
Low‑Cost Monitoring Setup

1. Choose a place that never sleeps for the monitor
Render’s free web service sleeps after 15 minutes of inactivity, so a monitor that lives inside the same environment would never run. I use a Cloudflare Worker because it stays warm on the free plan and can call any public URL without extra cost.
2. Write the check script – let the LLM give you a starter
Prompt (Groq, :free):
“Write a TypeScript Cloudflare Worker that GETs https://my‑app.onrender.com/health, measures response time, queries SELECT 1 FROM pg_sleep(0) on a Neon DB, and posts a JSON payload to a Slack webhook if latency > 2 s or DB error occurs.”
The LLM returned a concise script; I trimmed it to the essentials and added type safety:
// src/monitor.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const start = Date.now();
// 1️⃣ health endpoint
const healthRes = await fetch(env.APP_URL + "/health");
const healthOk = healthRes.ok;
const healthTime = Date.now() - start;
// 2️⃣ simple DB ping
let dbOk = false;
let dbTime = 0;
try {
const dbStart = Date.now();
const dbRes = await fetch(env.NEON_PROXY, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: "SELECT 1",
}),
});
dbOk = dbRes.ok;
dbTime = Date.now() - dbStart;
} catch (_) {
dbOk = false;
}
// 3️⃣ alert if anything is bad
if (!healthOk || healthTime > 2000 || !dbOk || dbTime > 2000) {
await fetch(env.SLACK_WEBHOOK, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `⚠️ Monitor alert:
- health: ${healthOk ? "OK" : "FAIL"} (${healthTime} ms)
- db: ${dbOk ? "OK" : "FAIL"} (${dbTime} ms)`,
}),
});
}
// Worker must return something; 204 keeps the log clean
return new Response(null, { status: 204 });
},
} as ExportedHandler<Env>;
interface Env {
APP_URL: string; // e.g. https://my-app.onrender.com
NEON_PROXY: string; // Neon connection string via https://neon.tech/docs/api
SLACK_WEBHOOK: string; // secret
}
Why this works on a free tier
- Cloudflare Workers have 100,000 requests/month free – more than enough for a 5‑minute interval (≈8,640 calls).
- The script does not keep any state, so there is no cold‑start penalty beyond the first request each day.
- All secrets live in the Worker’s environment variables; no extra VM.
3. Deploy the worker
# wrangler.toml (partial)
name = "budget-monitor"
compatibility_date = "2024-09-01"
[vars]
APP_URL = "https://my-app.onrender.com"
NEON_PROXY = "https://<project>.neon.tech/v1/execute"
SLACK_WEBHOOK = "<secret>"
wrangler publish
4. Schedule the run
Cloudflare Cron Triggers (free) let you run the worker every 5 minutes:
[triggers]
crons = ["*/5 * * * *"]
5. Failure modes to watch
| Symptom | Likely cause | Fix |
|---|---|---|
| No Slack messages despite alerts | Worker hit the 100k request limit | Move to a longer interval or add a second Worker on a different account |
| 429 from Neon | Exceeded connection limit (max 20 on free tier) | Use a single pooled connection per request; avoid opening many connections in parallel |
| Worker returns 500 | Syntax error after editing script | Check Cloudflare logs; they are free and show stack traces |
Implementing JWT Auth with Postgres

1. Keep the token on the server, not in localStorage
I store a signed JWT in an HttpOnly, Secure cookie. The token contains only a user ID and an expiration; all role checks hit the DB. This avoids the “JWT everywhere” trap where the front‑end trusts its own copy.
2. Minimal TypeScript API (Node + Express)
// src/auth.ts
import express from "express";
import jwt from "jsonwebtoken";
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET!;
const COOKIE_OPTS = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week
};
router.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await pool.query(
"SELECT id, password_hash FROM users WHERE email=$1",
[email]
);
if (!user.rowCount) return res.status(401).json({ error: "Invalid" });
// In a real project use bcrypt; here we compare plain text for brevity
if (user.rows[0].password_hash !== password)
return res.status(401).json({ error: "Invalid" });
const token = jwt.sign(
{ sub: user.rows[0].id },
JWT_SECRET,
{ expiresIn: "7d" }
);
res
.cookie("auth", token, COOKIE_OPTS)
.json({ message: "Logged in" });
});
router.post("/logout", (_, res) => {
res.clearCookie("auth", COOKIE_OPTS).json({ message: "Logged out" });
});
export default router;
3. Middleware to protect routes
// src/middleware.ts
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const JWT_SECRET = process.env.JWT_SECRET!;
export async function authGuard(
req: Request,
res: Response,
next: NextFunction
) {
const token = req.cookies?.auth;
if (!token) return res.status(401).json({ error: "Unauthenticated" });
try {
const payload = jwt.verify(token, JWT_SECRET) as { sub: string };
const { rows } = await pool.query(
"SELECT id, role FROM users WHERE id=$1",
[payload.sub]
);
if (!rows.length) throw new Error("User not found");
// attach user info for downstream handlers
(req as any).user = rows[0];
next();
} catch (e) {
res.status(401).json({ error: "Invalid token" });
}
}
4. Use the guard in a protected endpoint
// src/posts.ts
import express from "express";
import { authGuard } from "./middleware";
const router = express.Router();
router.get("/me", authGuard, async (req, res) => {
const user = (req as any).user;
const { rows } = await pool.query(
"SELECT title, created_at FROM posts WHERE author_id=$1 ORDER BY created_at DESC LIMIT 5",
[user.id]
);
res.json(rows);
});
export default router;
5. Trade‑offs and limits
- Free Neon connection limit – 20 concurrent connections. The middleware opens a new connection per request; with Render’s free tier (single dyno) this is fine, but a burst of parallel API calls can hit the limit and cause 429s. Mitigation: enable
pgbounceron Neon (free) or reuse a singlePoolinstance across modules (as shown). - Cookie size – JWTs stay under 1 KB, well within the 4 KB cookie limit.
- Statelessness vs revocation – Because the token is short‑lived (7 days) and we check the DB on each request, revoking a user is just a row delete or role change; no blacklist needed.
6. Failure modes to anticipate
| Symptom | Likely cause | Fix |
|---|---|---|
| 401 on every request after deploy | JWT_SECRET mismatch between API and worker that generates tokens |
Keep the secret in a single Render env var and reference it everywhere |
| DB connection errors after a few minutes of traffic | Neon hit its connection cap | Add pool.max = 10 to limit client‑side concurrency; enable Neon’s connection pooling |
| Cookie not sent on subsequent requests | secure flag on localhost |
Set secure: false when NODE_ENV !== "production" |
What I’ll Do on Monday

- Run the monitor locally (
wrangler dev) to verify the health check and DB ping work against the staging Render URL. - Add a test user in Neon, run the login flow, and confirm the
authcookie is set and the/meendpoint returns the expected posts. - Check Render logs for any “connection limit exceeded” warnings; if they appear, lower the pool size or enable Neon’s built‑in pooling.
- Push the Worker (
wrangler publish) and watch the first few cron executions in Cloudflare’s dashboard; adjust the 2 s latency threshold if the free tier’s cold start skews results. - Document the env‑var list in the repo’s README so future contributors know which secrets are required for both the API and the monitor.
Related reading
- Optimizing TypeScript Full‑Stack Development with Postgres, JWT Authentication, and LLM‑Based Automation for Cost‑Effective Deployments
- Building a Low‑Cost Full‑Stack TypeScript App with Postgres, JWT Auth, and LLM‑Powered Automation
Serverless Postgres Connection Management

When you move a Node/TS API to a free‑tier Postgres provider like Neon, the biggest surprise is the connection limit. Neon’s free plan caps you at 20 concurrent connections and enforces a 30‑second idle timeout. If you ignore those numbers you’ll see “too many connections” errors after a handful of requests, especially when the API runs behind a CDN that re‑uses workers.
1. Use a single pooled client per process
Render (or Railway, Fly) spins up a new Node process per instance. Inside that process I create one Pool and reuse it for every request. The pool is configured to never exceed the provider’s limit, and I also enable Neon’s built‑in pooling (pgbouncer) to share connections across instances.
// src/db.ts
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
// Neon recommends max 10 for free tier; leave room for internal connections
max: 10,
// 5 s idle timeout keeps the pool from holding dead sockets
idleTimeoutMillis: 5_000,
// Enable Neon’s server‑side pooling
statement_timeout: 10_000,
});
export async function query<T = any>(text: string, params?: any[]): Promise<T[]> {
const client = await pool.connect();
try {
const res = await client.query<T>(text, params);
return res.rows;
} finally {
client.release();
}
}
Why this matters:
- The pool lives for the lifetime of the process, so you never open a new TCP socket per request.
max: 10guarantees you stay under Neon’s 20‑connection ceiling even if Render runs two instances.idleTimeoutMillisforces idle sockets to close quickly, letting Neon reclaim resources before the 30‑second hard limit.
2. Graceful shutdown on SIGTERM
Render sends a SIGTERM before it kills a container. If you don’t close the pool, the connection stays open until the provider’s timeout, which can cause “connection leak” warnings.
process.on("SIGTERM", async () => {
console.log("🛑 Received SIGTERM – closing DB pool");
await pool.end();
process.exit(0);
});
3. Detect and back‑off on ECONNREFUSED
Free tiers occasionally spin down the underlying VM, returning ECONNREFUSED. I wrap the query helper in a retry loop with exponential back‑off. Three attempts are enough to survive a cold start without hammering the DB.
import delay from "delay";
export async function safeQuery<T = any>(sql: string, params?: any[]): Promise<T[]> {
const maxAttempts = 3;
let attempt = 0;
while (true) {
try {
return await query<T>(sql, params);
} catch (err: any) {
if (err.code === "ECONNREFUSED" && attempt < maxAttempts) {
attempt++;
const wait = 100 * 2 ** attempt; // 200ms, 400ms, 800ms
console.warn(`⚡ DB connection refused, retry ${attempt}/${maxAttempts} after ${wait}ms`);
await delay(wait);
continue;
}
throw err;
}
}
}
4. Monitoring the pool size
I expose a tiny health endpoint that returns the current pool stats. Cloudflare Workers can ping it every minute; if the totalCount approaches the max, I get an early warning before the DB throws an error.
// src/health.ts
import { RequestHandler } from "express";
export const poolHealth: RequestHandler = (_req, res) => {
const stats = {
total: pool.totalCount,
idle: pool.idleCount,
waiting: pool.waitingCount,
};
res.json(stats);
};
Add it to the router:
app.get("/health/db", poolHealth);
5. Failure modes to watch
| Symptom | Likely cause | Fix |
|---|---|---|
| “too many clients already” error after a traffic spike | max too high for free tier or multiple Render instances |
Lower max to 5, enable Neon pooling, or add a rate‑limiter |
| Connections linger for >30 s after idle | idleTimeoutMillis too large or missing pool.end() on SIGTERM |
Reduce idle timeout, ensure graceful shutdown |
Random ECONNREFUSED on first request after deploy |
Neon VM cold‑started, worker still warm | Keep the retry logic, add a warm‑up cron (see next section) |
Deploying the API on Render with Cold‑Start Mitigation

Render’s free web service gives you 512 MiB RAM, 0.5 CPU, and 30 seconds of idle time before the container sleeps. The first request after sleep incurs a cold start (≈1 s for a TS bundle). That latency is fine for a personal blog, but it breaks a health check that expects sub‑500 ms responses.
1. Keep‑alive ping via a scheduled job
Render lets you add a cron job that hits any endpoint on the same service. I schedule a GET /ping every 5 minutes. The endpoint does nothing but return 200 OK, keeping the container warm.
// src/ping.ts
import { RequestHandler } from "express";
export const ping: RequestHandler = (_req, res) => {
res.sendStatus(200);
};
Add to router:
app.get("/ping", ping);
In render.yaml:
services:
- type: web
name: api
env: node
buildCommand: npm run build
startCommand: npm start
envVars:
- key: DATABASE_URL
fromSecret: neon_url
cron:
- name: keep-warm
schedule: "*/5 * * * *"
command: curl -s https://api.mydomain.com/ping
2. Reduce bundle size for faster start
I use esbuild with --minify and --external:pg so that the heavy pg library stays external and is loaded lazily. The final bundle is ~1.2 MB gzipped, which renders in <300 ms on Render’s hardware.
// package.json scripts
{
"build": "esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js --minify --external:pg"
}
3. Lazy‑load heavy modules
Only the auth routes need pg. By moving the import inside the handler we avoid loading the driver on every cold start.
// src/routes/auth.ts
import type { RequestHandler } from "express";
export const login: RequestHandler = async (req, res) => {
const { Pool } = await import("pg"); // lazy
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// … rest of login logic
};
4. Health check that tolerates cold starts
Cloudflare Workers (our monitor) expect a 2 s window. I configure the health endpoint to return 202 Accepted if the DB ping takes >1 s, signalling “still warming”.
app.get("/health", async (_req, res) => {
const start = Date.now();
try {
await safeQuery("SELECT 1");
const elapsed = Date.now() - start;
if (elapsed > 1_000) {
return res.status(202).json({ status: "warming", latency: elapsed });
}
res.json({ status: "ok", latency: elapsed });
} catch (e) {
res.status(500).json({ error: (e as Error).message });
}
});
5. Failure modes specific to Render
| Symptom | Likely cause | Fix |
|---|---|---|
| First request after a few hours takes >5 s | Cron job failed, container slept | Verify cron logs, add a second ping from an external service (e.g., UptimeRobot) |
| Memory OOM on cold start | Bundle includes dev‑only code | Run npm prune --production before build, double‑check esbuild --external |
| 502 from Render after deploy | Start command exits early (missing env var) | Add a sanity check at startup: if (!process.env.DATABASE_URL) throw new Error("DB URL missing") |
LLM‑Powered Log Summarizer as a Cloudflare Worker

Free‑tier monitoring gives you raw request/response logs, but reading them line‑by‑line is tedious. I built a tiny Worker that pulls the last 100 lines from Render’s log endpoint, sends them to a free LLM (Groq mixtral-8x7b-32768), and returns a concise bullet list. The worker runs on the edge, so the latency is dominated by the LLM call, not by the API.
1. Permissions and secrets
Render can expose logs via an API token. I store that token in a Cloudflare Worker secret called RENDER_LOG_TOKEN. The LLM API key lives in GROQ_API_KEY.
wrangler secret put RENDER_LOG_TOKEN
wrangler secret put GROQ_API_KEY
2. Worker code (TypeScript)
// workers/log-summarizer.ts
import { Router } from "itty-router";
import { fetch } from "undici";
const router = Router();
interface LogEntry {
timestamp: string;
level: string;
message: string;
}
// Helper: fetch raw logs from Render
async function getRenderLogs(): Promise<LogEntry[]> {
const resp = await fetch(
"https://api.render.com/v1/services/api/logs?limit=100",
{
headers: {
Authorization: `Bearer ${RENDER_LOG_TOKEN}`,
Accept: "application/json",
},
}
);
if (!resp.ok) throw new Error(`Render logs error ${resp.status}`);
const data = (await resp.json()) as LogEntry[];
return data;
}
// Helper: call Groq LLM
async function summarizeWithGroq(logs: LogEntry[]): Promise<string> {
const prompt = `
You are a devops assistant. Summarize the following logs in 5 bullet points.
Focus on errors, warnings, and any unusual latency spikes.
Do not mention timestamps unless they are critical.
${JSON.stringify(logs, null, 2)}
`;
const resp = await fetch("https://api.groq.com/openai/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${GROQ_API_KEY}`,
},
body: JSON.stringify({
model: "mixtral-8x7b-32768",
messages: [{ role: "user", content: prompt }],
temperature: 0.2,
max_tokens: 300,
}),
});
if (!resp.ok) {
const txt = await resp.text();
throw new Error(`Groq error ${resp.status}: ${txt}`);
}
const json = await resp.json();
return json.choices[0].message.content.trim();
}
// Route: /summarize
router.get("/summarize", async (_req, _env, ctx) => {
try {
const logs = await getRenderLogs();
const summary = await summarizeWithGroq(logs);
return new Response(summary, {
headers: { "content-type": "text/plain; charset=utf-8" },
});
} catch (e) {
const err = e as Error;
return new Response(`❗ ${err.message}`, { status: 500 });
}
});
export default {
fetch: router.handle,
};
Key points:
- The worker stays under the free tier because each request costs < 0.001 USD on Groq’s free quota.
- I cap the log fetch to 100 lines; more would increase latency and risk hitting the 10 ms CPU limit on Workers.
- The prompt is deliberately short; the LLM can infer patterns without the full raw text.
3. Deploy and test
wrangler publish --name log-summarizer
# Test
curl https://log-summarizer.mydomain.workers.dev/summarize
You should see something like:
• 3× “DB connection limit exceeded” warnings at 12:04 UTC
• 1× “JWT signature verification failed” – likely secret mismatch
• 2× “slow query (>200 ms)” on SELECT posts
• No critical errors in the last hour
• All health checks returned 200 OK
4. Failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| 429 from Groq after a few runs | Free tier rate limit (30 req/min) | Add a simple in‑memory cache (store last summary for 1 min) |
| Empty response | Render log token missing or expired | Rotate the token, store new secret, redeploy |
| Worker times out (10 s) | Log fetch >5 s or LLM latency spikes | Reduce log limit, enable stream: false on fetch, or fallback to a static “no data” message |
CI/CD with GitHub Actions for Free‑Tier Safety
Deploying manually works for a hobby project, but a repeatable pipeline prevents accidental credential leaks and keeps the free‑tier quotas in check.
1. Workflow overview
name: Deploy Full‑Stack
on:
push:
branches: [main]
jobs:
lint-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
build-deploy-api:
needs: lint-test
runs-on: ubuntu-latest
env:
RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
steps:
- uses: actions/checkout@v4
- name: Install deps
run: npm ci
- name: Build
run: npm run build
- name: Deploy to Render
run: |
curl -X POST "https://api.render.com/v1/services/api/deploys" \
-H "Authorization: Bearer $RENDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"clearCache":true}'
Why this matters:
lint-testruns on every push, catching type errors before they hit the free tier.- The
build-deploy-apistep uses Render’s Deploy API instead of a Git‑push, which avoids the extra Git‑fetch that would consume the free tier’s bandwidth quota. - Secrets are never printed; they stay in GitHub’s encrypted store.
2. Deploying the Cloudflare Worker
A second job pushes the Worker only when the API build succeeds.
deploy-worker:
needs: build-deploy-api
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@
## Environment Variable Management on Render
Render gives you a UI for secrets, but I prefer to keep a single source of truth in a `.env.example` file that lives in the repo. The file lists every variable the app expects, with a short comment describing its purpose. During CI I copy the example, inject the real values from GitHub Secrets, and run the build.
```ts
// src/config.ts
import * as z from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production"]).default("development"),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
LLM_API_KEY: z.string(),
// Render‑specific
RENDER_SERVICE_ID: z.string(),
});
export const env = envSchema.parse(process.env);
Why this matters
- Fail fast – If a secret is missing, the app crashes on startup instead of silently using a default that could leak data.
- Auditability – The schema is version‑controlled; any change to required env vars shows up in PR diffs.
- Local dev parity – I drop a
.envfile generated from the example, sonpm run devworks exactly like the Render container.
In the GitHub Actions workflow I add a step before the build to generate the .env file:
- name: Generate .env from secrets
run: |
cat <<EOF > .env
NODE_ENV=production
PORT=10000
DATABASE_URL=${{ secrets.DATABASE_URL }}
JWT_SECRET=${{ secrets.JWT_SECRET }}
LLM_API_KEY=${{ secrets.LLM_API_KEY }}
RENDER_SERVICE_ID=${{ secrets.RENDER_SERVICE_ID }}
EOF
The file never gets committed; it lives only in the runner’s temporary filesystem.
Handling Rate Limits with Exponential Backoff
Both Render’s Deploy API and Neon’s HTTP endpoint enforce request quotas. Hitting a 429 on a free tier can stall a CI run for minutes. I wrap every external call in a tiny helper that retries with jitter.
// src/utils/retry.ts
export async function retry<T>(
fn: () => Promise<T>,
attempts = 5,
baseDelayMs = 200
): Promise<T> {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err: any) {
if (err?.status !== 429) throw err; // not a rate‑limit error
const jitter = Math.random() * 100;
const delay = baseDelayMs * 2 ** i + jitter;
console.warn(`Rate limited, retry ${i + 1}/${attempts} after ${delay}ms`);
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error("Exceeded retry attempts");
}
I use it in the Deploy step:
- name: Deploy to Render with retry
run: |
node -e "
const { retry } = require('./dist/utils/retry');
const fetch = require('node-fetch');
retry(() => fetch('https://api.render.com/v1/services/api/deploys', {
method: 'POST',
headers: {
Authorization: 'Bearer $RENDER_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({ clearCache: true })
}).then(r => {
if (!r.ok) throw Object.assign(new Error('Deploy failed'), { status: r.status });
return r.json();
}));
"
Failure mode – If the free tier’s daily quota is exhausted, the helper will keep retrying until the job times out. The CI run then fails fast, alerting me on Slack (via a simple webhook step). I can then manually bump the quota or wait for the next day.
Testing JWT Refresh Flow Locally
JWTs are stateless, but I still need a refresh token to keep sessions alive without forcing the user to log in again. The tricky part is that the refresh endpoint must reject stolen tokens while still being usable from localhost. I spin up a tiny SQLite DB for dev, mirroring the Postgres schema, and run the same token‑generation code.
// src/auth/refresh.ts
import { env } from "../config";
import jwt from "jsonwebtoken";
import { db } from "../db";
export async function refreshToken(oldRefresh: string) {
const payload = jwt.verify(oldRefresh, env.JWT_SECRET) as {
sub: string;
type: "refresh";
iat: number;
exp: number;
};
if (payload.type !== "refresh") throw new Error("Invalid token type");
// Verify the token is still stored in DB (revocation check)
const row = await db.query(
`SELECT id FROM refresh_tokens WHERE token = $1 AND user_id = $2`,
[oldRefresh, payload.sub]
);
if (!row.rowCount) throw new Error("Refresh token revoked");
// Issue new pair
const access = jwt.sign(
{ sub: payload.sub, type: "access" },
env.JWT_SECRET,
{ expiresIn: "15m" }
);
const refresh = jwt.sign(
{ sub: payload.sub, type: "refresh" },
env.JWT_SECRET,
{ expiresIn: "7d" }
);
// Rotate: delete old, store new
await db.query(
`DELETE FROM refresh_tokens WHERE token = $1`,
[oldRefresh]
);
await db.query(
`INSERT INTO refresh_tokens (user_id, token) VALUES ($1, $2)`,
[payload.sub, refresh]
);
return { access, refresh };
}
To test locally I add a script:
// package.json
"scripts": {
"test:refresh": "ts-node -e \"require('./src/auth/refresh').refreshToken(process.argv[1])\""
}
Run it with a known dev refresh token:
npm run test:refresh eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
If the token is revoked, the script exits with an error code, which CI interprets as a failure. This gives me confidence that the production flow (which uses Neon) behaves the same, without ever touching the live DB during development.
Optimizing Neon Connection Pool for the Free Tier
Neon’s serverless Postgres spins down after 5 minutes of inactivity. Each cold start adds ~1 s latency, and the free tier caps you at 20 concurrent connections. I keep the pool tiny and recycle connections aggressively.
// src/db.ts
import { Pool } from "pg";
import { env } from "./config";
export const pool = new Pool({
connectionString: env.DATABASE_URL,
max: 2, // never exceed free tier limit
idleTimeoutMillis: 30_000, // close after 30 s of idle
connectionTimeoutMillis: 5_000,
});
export const db = {
async query<T = any>(text: string, params?: any[]) {
const client = await pool.connect();
try {
const res = await client.query<T>(text, params);
return res;
} finally {
client.release();
}
},
};
Why this matters
- Cold‑start mitigation – The API sends a lightweight “keep‑alive” query (
SELECT 1) every 4 minutes from a Render cron job. That prevents Neon from sleeping while the app is actively used, but still lets it spin down during true idle periods (e.g., overnight). - Connection safety – With
max: 2I stay well under the 20‑connection ceiling, even when Render spawns two worker instances during a spike. If a third request arrives, it queues instead of opening a new socket, avoiding the “too many connections” error that would otherwise kill the free tier.
Cron job definition (Render’s built‑in scheduler):
# render.yaml
jobs:
keep-neon-alive:
schedule: "*/4 * * * *"
command: "node -e \"require('./dist/db').db.query('SELECT 1')\""
Storing Markdown Content Efficiently in Postgres
My blog stores each post’s markdown, front‑matter, and a JSON‑baked excerpt in a single table. The schema is deliberately simple to keep the free tier’s storage cost low.
-- migrations/2024-09-01-create_posts.sql
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
markdown TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
When a new post is saved via the API, I extract the front‑matter with gray-matter and store it as JSONB. This lets me filter by tags or dates without parsing markdown at query time.
// src/routes/posts.ts
import matter from "gray-matter";
import { db } from "../db";
export async function createPost(slug: string, raw: string) {
const { data, content } = matter(raw);
await db.query(
`INSERT INTO posts (slug, title, markdown, metadata)
VALUES ($1, $2, $3, $4)`,
[slug, data.title, content, data]
);
}
Failure mode – If the markdown exceeds 1 MB (the free tier’s row size limit), the insert fails with ERROR: value too long for type text. I guard against this in the API:
if (Buffer.byteLength(content, "utf8") > 950_000) {
throw new Error("Post too large for free tier storage");
}
Image Handling: Offload to Object Storage
Embedding base64 images in markdown quickly blows up the Postgres row size. My rule: any image larger than 10 KB gets uploaded to Cloudflare R2, and the markdown receives a signed URL.
// src/utils/image.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { env } from "../config";
const r2 = new S3Client({
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
region: "auto",
credentials: {
accessKeyId: env.CLOUDFLARE_R2_ACCESS_KEY,
secretAccessKey: env.CLOUDFLARE_R2_SECRET_KEY,
},
});
export async function uploadImage(buffer: Buffer, filename: string) {
const key = `images/${Date.now()}-${filename}`;
await r2.send(
new PutObjectCommand({
Bucket: env.CLOUDFLARE_R2_BUCKET,
Key: key,
Body: buffer,
ContentType: "image/png",
})
);
// R2 URLs are public by default; if you need signed URLs, generate them here.
return `https://${env.CLOUDFLARE_R2_BUCKET}.r2.cloudflarestorage.com/${key}`;
}
In the markdown processing pipeline:
if (imageSize > 10_000) {
const url = await uploadImage(imageBuffer, originalName);
markdown = markdown.replace(``, ``);
}
This keeps the posts.markdown column comfortably under the row limit while still delivering fast CDN‑cached images to readers.
Monitoring API Latency with a Cheap Cloudflare Worker
I already have a log summarizer worker, but I also need a heartbeat that records request latency and pushes it to a free Grafana Cloud instance. The worker runs on the edge, so latency measurement adds virtually no overhead.
// worker/latency.ts
addEventListener("fetch", (event) => {
const start = Date.now();
event.respondWith(
fetch(event.request).then((resp) => {
const duration = Date.now() - start;
// Fire‑and‑forget to Grafana Loki endpoint
fetch("https://logs-prod-us-east-0.grafana.net/api/prom/push", {
method: "POST",
headers: {
Authorization: `Bearer ${ENV.GRAFANA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
streams: [
{
stream: {
job: "api-latency",
method: event.request.method,
path: new URL(event.request.url).pathname,
},
values: [[`${Date.now()}000000`, `${duration}`]],
},
],
}),
}).catch(() => {}); // swallow errors – we don’t want to break the response
return resp;
})
);
});
Deploy it as a separate route on the same Cloudflare Pages site:
# wrangler.toml
[[routes]]
pattern = "api.example.com/*"
script = "latency"
Why this matters – Grafana’s free tier gives me 7 days of retention. I can set alerts for “average latency > 300 ms” and get a Slack webhook before the user experience degrades. The worker itself stays within the free 100 ms CPU limit because it only does a tiny fetch and a non‑blocking POST.
Cost‑Aware Feature Flags
Free tiers often have hidden costs: extra egress, extra function invocations, or storage overage. I guard expensive features behind a simple flag stored in the database. The flag can be toggled without a redeploy.
-- migrations/2024-09-05-feature_flags.sql
CREATE TABLE feature_flags (
name TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
description TEXT
);
INSERT INTO feature_flags (name, enabled, description)
VALUES
('image_optimisation', FALSE, 'Run image compression on upload');
Utility to read the flag:
// src/flags.ts
import { db } from "./db";
export async function isEnabled(name: string): Promise<boolean> {
const res = await db.query<{ enabled: boolean }>(
`SELECT enabled FROM feature_flags WHERE name = $1`,
[name]
);
return res.rowCount ? res.rows[0].enabled : false;
}
Usage in the upload pipeline:
if (await isEnabled("image_optimisation")) {
buffer = await compress(buffer); // cheap library, runs only when flag is on
}
When I notice Neon’s egress approaching the free‑tier limit, I flip the flag off via a one‑off SQL console command, instantly cutting the extra traffic without a code change.
Deploying a Separate LLM‑Powered Health‑Check Worker
The log summarizer worker is great for post‑mortems, but I also want a proactive health‑check that asks an LLM to evaluate recent logs and raise a ticket if it detects an error pattern. I keep this worker tiny and trigger it only once per hour.
// worker/healthcheck.ts
addEventListener("
## Image credits
- Cover: AI-generated illustration
- inline: rawpixel / Pixabay
- inline: Tumisu / Pixabay
- inline: cookieone / Pixabay
- inline: the_iop / Pixabay
- inline: Techaltruistic / Pixabay
- inline: Lucent_Designs_dinoson20 / Pixabay