DEV Community

Dakota Huang
Dakota Huang

Posted on

Zero to Verified: A Free Model API on a Free Server, Stage by Stage

Free model endpoints fail. Free servers restart. A stack you cannot verify will fail twice.

This tutorial builds a minimal model-backed API from zero to a public URL. Every stage ends with a check. If a stage fails, you fix it before moving on. Total time is about 40 minutes.

You will build a small Node service with two routes. /health reports liveness. /complete forwards a prompt to a free model endpoint and returns JSON. Then you deploy it to a free server and verify the public URL. This is not a production architecture. It is a testable foundation you can extend.

Stage 0: Prerequisites

You need three things: Node 20 or newer, curl, and a free server that can run a Node process. This tutorial uses MonkeyCode's free server option and its free model access for the upstream call. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The same steps work with any free host and any endpoint that accepts JSON over HTTP.

Verify the toolchain first:

node --version   # v20.0.0 or newer
curl --version
Enter fullscreen mode Exit fullscreen mode

If node --version errors, install Node before continuing. A broken toolchain will fake every later failure.

Stage 1: A server that answers /health

Create a project directory and one file.

mkdir free-stack && cd free-stack
touch server.mjs
Enter fullscreen mode Exit fullscreen mode

Write a minimal HTTP server:

// server.mjs
import http from "node:http";

const PORT = process.env.PORT || 3000;

http.createServer((req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  if (req.method === "GET" && url.pathname === "/health") {
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify({ ok: true, ts: Date.now() }));
    return;
  }
  res.writeHead(404);
  res.end();
}).listen(PORT, () => console.log(`listening on :${PORT}`));
Enter fullscreen mode Exit fullscreen mode

Start it and check:

node server.mjs &
curl -fsS http://localhost:3000/health
Enter fullscreen mode Exit fullscreen mode

You should see {"ok":true,"ts":...}. If you get nothing, the server did not start. Read the error. Fix it. Do not proceed.

Stage 2: Add the model route

Now add the route that matters. It reads a prompt, calls the free model endpoint, and returns the output.

Two guards matter: a timeout and a response size cap. Free endpoints hang. Free endpoints also return huge payloads. Both will kill a small server.

Why these values? 15 seconds covers most interactive requests. 64 KB covers most short completions. Both are small enough to keep a free server alive.

Replace server.mjs with this:

import http from "node:http";

const PORT = process.env.PORT || 3000;
const MODEL_URL = process.env.MODEL_URL;
const TIMEOUT_MS = 15_000;
const MAX_BYTES = 64 * 1024;

const readJson = async (req) => {
  let body = "";
  for await (const chunk of req) {
    body += chunk;
    if (body.length > 10_000) throw new Error("request too large");
  }
  return body ? JSON.parse(body) : {};
};

const callModel = async (prompt) => {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
  try {
    const res = await fetch(MODEL_URL, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ prompt }),
      signal: controller.signal,
    });
    const text = await res.text();
    if (text.length > MAX_BYTES) throw new Error("response too large");
    return JSON.parse(text);
  } finally {
    clearTimeout(timer);
  }
};

http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  const send = (status, obj) => {
    res.writeHead(status, { "content-type": "application/json" });
    res.end(JSON.stringify(obj));
  };

  if (req.method === "GET" && url.pathname === "/health") {
    return send(200, { ok: true, ts: Date.now() });
  }

  if (req.method === "POST" && url.pathname === "/complete") {
    try {
      const { prompt } = await readJson(req);
      if (!prompt) return send(400, { ok: false, error: "prompt required" });
      const data = await callModel(prompt);
      return send(200, { ok: true, output: data.output ?? data });
    } catch (err) {
      return send(502, { ok: false, error: err.message });
    }
  }

  send(404, { ok: false, error: "not found" });
}).listen(PORT, () => console.log(`listening on :${PORT}`));
Enter fullscreen mode Exit fullscreen mode

The upstream contract varies by provider. This wrapper accepts output if present, otherwise it returns the raw payload. Adjust the field name to your provider.

Start it against a real endpoint and test:

MODEL_URL="https://your-free-model-endpoint" node server.mjs &
curl -fsS -X POST http://localhost:3000/complete \
  -H 'content-type: application/json' \
  -d '{"prompt":"Return the number 7."}'
Enter fullscreen mode Exit fullscreen mode

Run it twice. Free endpoints fail intermittently. One success proves nothing. Two successes is a pattern.

Stage 3: Verify the failure path with a fake upstream

This stage is the one most tutorials skip. You must verify the failure path before you deploy. A fake upstream makes the test deterministic.

Start a slow fake upstream on port 9999:

node -e 'require("http").createServer((req,res)=>{setTimeout(()=>res.end("{}"),30000)}).listen(9999)'
Enter fullscreen mode Exit fullscreen mode

Point the real server at the fake:

MODEL_URL=http://localhost:9999 node server.mjs
curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/complete \
  -H 'content-type: application/json' -d '{"prompt":"hi"}'
Enter fullscreen mode Exit fullscreen mode

Expect a 502 after about 15 seconds. If you get a hang, the timeout is broken. Fix it before deploying. A deployed hang is an incident. A local hang is a bug.

Stage 4: Deploy to the free server

Now move the service to a free server. The exact commands depend on your host. This is the generic path: copy the file, set the environment variable, start the process.

scp server.mjs user@your-free-server:~/
ssh user@your-free-server
export MODEL_URL="https://your-free-model-endpoint"
node server.mjs
Enter fullscreen mode Exit fullscreen mode

A bare node process dies when your SSH session closes. Use a process manager:

# on the server
npm init -y
npm install -g pm2
pm2 start server.mjs --name free-stack
pm2 save
Enter fullscreen mode Exit fullscreen mode

pm2 restarts the service after crashes and reboots. MonkeyCode's free server option can host this exact file. The checks work the same on any host, so the choice is yours.

Stage 5: Verify the public URL

Do not trust the deploy log. Trust the response. Point the same checks at the public URL:

BASE=https://your-app.example.com
curl -fsS $BASE/health
curl -fsS -X POST $BASE/complete \
  -H 'content-type: application/json' \
  -d '{"prompt":"Write a one-line JavaScript function that returns 42."}'
Enter fullscreen mode Exit fullscreen mode

Then run a repeatable verification script. Save this as verify.mjs:

// run: node verify.mjs https://your-app.example.com
const base = process.argv[2] ?? "http://localhost:3000";

const checks = [
  ["health returns ok", async () => {
    const res = await fetch(`${base}/health`);
    if (res.status !== 200) throw new Error(`status ${res.status}`);
    const data = await res.json();
    if (!data.ok) throw new Error("ok flag missing");
  }],
  ["complete returns output", async () => {
    const res = await fetch(`${base}/complete`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ prompt: "Return the number 7." }),
    });
    const data = await res.json();
    if (!data.ok || !data.output) throw new Error("bad payload");
  }],
  ["complete rejects missing prompt", async () => {
    const res = await fetch(`${base}/complete`, { method: "POST" });
    if (res.status !== 400) throw new Error(`status ${res.status}`);
  }],
];

let failed = 0;
for (const [name, fn] of checks) {
  try {
    await fn();
    console.log(`PASS ${name}`);
  } catch (err) {
    failed++;
    console.error(`FAIL ${name}: ${err.message}`);
  }
}
process.exit(failed ? 1 : 0);
Enter fullscreen mode Exit fullscreen mode

Run it locally, then against the deployed URL:

node verify.mjs http://localhost:3000
node verify.mjs https://your-app.example.com
Enter fullscreen mode Exit fullscreen mode

Both should print three PASS lines. If the deployed run fails, compare the two outputs. The difference is your deployment bug.

Run the script on a schedule if you want to catch degradation. A cron job every 10 minutes is enough for a prototype. Record the output. Trends matter more than single runs.

Limitations

This stack is not for everyone. Do not use it for:

  • Production traffic with an uptime promise. Free tiers have no SLA.
  • Sensitive data. Free endpoints and free servers log what they receive. Assume everything is readable.
  • High-volume workloads. Rate limits are real and often undocumented. The 502 path is your only backstop.
  • Long-running batch jobs. The 15-second timeout will kill them.

What this stack is good for: interactive prototypes, internal tools, and experiments where a failed request can be retried by a human.

The timeout is a feature, not a bug. A fast failure is cheaper than a hung connection. Your monitoring should count 502s, not hide them.

Where to go from here

The whole stack is one file, one verification script, and one deploy. You can run it on MonkeyCode's free server with its free model access, or anywhere else. The verification steps are provider-agnostic. Start with the fake upstream. That is the only failure you can control.

Top comments (0)