TypeScript full-stack deployment illustration
2026-09-01 TypeScript full-stack deployment 13 min journal / budget-ts-fullstack

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

TypeScript full-stack deployment
Photo by rawpixel on Pixabay

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:

ts
// 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

bash
# 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>"
bash
wrangler publish

4. Schedule the run

Cloudflare Cron Triggers (free) let you run the worker every 5 minutes:

toml
[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

postgres database
Photo by Tumisu on Pixabay

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)

ts
// 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

ts
// 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

ts
// 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 pgbouncer on Neon (free) or reuse a single Pool instance 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

server rack
Photo by cookieone on Pixabay
  1. Run the monitor locally (wrangler dev) to verify the health check and DB ping work against the staging Render URL.
  2. Add a test user in Neon, run the login flow, and confirm the auth cookie is set and the /me endpoint returns the expected posts.
  3. Check Render logs for any “connection limit exceeded” warnings; if they appear, lower the pool size or enable Neon’s built‑in pooling.
  4. 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.
  5. 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.

Serverless Postgres Connection Management

jwt lock
Photo by the_iop on Pixabay

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.

ts
// 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: 10 guarantees you stay under Neon’s 20‑connection ceiling even if Render runs two instances.
  • idleTimeoutMillis forces 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.

ts
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.

ts
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.

ts
// 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:

ts
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

circuit board closeup
Photo by Techaltruistic on Pixabay

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.

ts
// src/ping.ts
import { RequestHandler } from "express";

export const ping: RequestHandler = (_req, res) => {
  res.sendStatus(200);
};

Add to router:

ts
app.get("/ping", ping);

In render.yaml:

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.

json
// 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.

ts
// 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”.

ts
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

ethernet cables
Photo by Lucent_Designs_dinoson20 on Pixabay

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.

bash
wrangler secret put RENDER_LOG_TOKEN
wrangler secret put GROQ_API_KEY

2. Worker code (TypeScript)

ts
// 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

bash
wrangler publish --name log-summarizer
# Test
curl https://log-summarizer.mydomain.workers.dev/summarize

You should see something like:

code
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 hourAll 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

yaml
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-test runs on every push, catching type errors before they hit the free tier.
  • The build-deploy-api step 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.

yaml
  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

  1. Fail fast – If a secret is missing, the app crashes on startup instead of silently using a default that could leak data.
  2. Auditability – The schema is version‑controlled; any change to required env vars shows up in PR diffs.
  3. Local dev parity – I drop a .env file generated from the example, so npm run dev works exactly like the Render container.

In the GitHub Actions workflow I add a step before the build to generate the .env file:

yaml
- 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.

ts
// 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:

yaml
- 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.

ts
// 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:

json
// package.json
"scripts": {
  "test:refresh": "ts-node -e \"require('./src/auth/refresh').refreshToken(process.argv[1])\""
}

Run it with a known dev refresh token:

bash
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.

ts
// 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: 2 I 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):

yaml
# 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.

sql
-- 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.

ts
// 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:

ts
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.

ts
// 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:

ts
if (imageSize > 10_000) {
  const url = await uploadImage(imageBuffer, originalName);
  markdown = markdown.replace(`![${alt}](${localPath})`, `![${alt}](${url})`);
}

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.

ts
// 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:

toml
# 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.

sql
-- 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:

ts
// 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:

ts
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.

ts
// 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

Questions

How can I keep a free‑tier Render service from sleeping during monitoring?

Use an external monitor like a Cloudflare Worker, which runs continuously on its free plan and can ping your Render endpoint.

What is the simplest way to add JWT authentication to a TypeScript API?

Create a JWT token on login, store it in an HttpOnly cookie, and verify the token in a middleware before protected routes.

Can I use Neon Postgres for free in production?

Neon offers a generous free tier suitable for small projects; just ensure you monitor connection limits and enable SSL.

How does an LLM‑generated monitoring script work?

You prompt an LLM to generate a script that checks health endpoints, measures DB latency, and sends alerts via a Slack webhook, then deploy it as a Cloudflare Worker.

Notes 0

Related reading