fastfn Part 1: I Had a Problem (and Introduced Lua to My Life)

The whole thing started with a complaint that is almost embarrassing to say out loud in 2026: I wanted to drop a Python file on disk and have it be an HTTP endpoint. That’s it. No Dockerfile. No requirements.txt unless I wanted one. No app = FastAPI() boilerplate, no uvicorn invocation, no “create a project” wizard that leaves behind seventeen config files I’m going to spend the next three afternoons deleting. And — this is the part where it got opinionated — I wanted a reasonable cold start. Not Lambda-cold, where the first request of the morning feels like a 404 with extra steps. Warm-ish. Human-scale.

I was tired of the shape of modern web frameworks. They’re beautiful, they scale, they have ecosystems, and they also ask you to mentally compile an entire mental model of their world before your first route responds with {"hello": "world"}. For a throwaway internal tool, that’s a tax you pay in attention. I had been paying it for a decade. I wanted to stop.

The other thing I wanted — and this is the feature that quietly drives the rest of the story — was polyglot by default. Not polyglot as in “microservices in different languages talking over gRPC.” Polyglot as in the same URL tree can have get.users.py, post.orders.js, and get.health.go, sitting next to each other in the same folder, behind the same gateway. Next.js file-based routing for file-based handlers, runtime-agnostic. That was the dream.

So the target was clear: a Function-as-a-Service thing, but local-first, with a CLI as the primary UI, and a file tree as the database. I called it fastfn. The README describes the ambition in one line: “Start with one file, a friendly CLI, and a route tree that can grow into a real API or SPA without a rewrite later” (README.md:8). The rest of this post is the story of how that one sentence turned into a Lua gateway, a persistent Python daemon, and a wire protocol with a 4-byte length prefix. It’s a story about discovering, slowly and with some embarrassment, that I was reinventing FastCGI.

Before I get to what fastfn is, I have to talk about what it rhymes with.

In the beginning there was CGI. The Common Gateway Interface was serverless before serverless was a brand. You put a script in /cgi-bin/, the web server fork’d and exec’d it on every single request, it read the request from environment variables and stdin, wrote the response to stdout, and exited. The OS cleaned up. Every request was a process. Every process was a universe that existed for 40 milliseconds and then died.

This is wonderful. This is also a performance crime.

text

CGI request model (what dies for you every time)

  client                 web server                 /cgi-bin/hello.pl
    |   HTTP GET /hello     |                            .
    | --------------------> | fork()                     .
    |                       | exec("perl hello.pl")  --> [ cold Perl VM ]
    |                       |                            [ parse script ]
    |                       |                            [ run handler ]
    |                       | <-- stdout --              [ die ]
    | <-- HTTP 200 --       |                            .
    |                       |                            .

Every request pays for process creation, interpreter warmup, library import, and tear-down. On a 1996 machine that hurt. On a 2026 machine it still hurts, just differently: cache-cold Python spends a non-trivial slice of time just importing its own standard library before your handler writes a single byte. I haven’t profiled this end-to-end for fastfn specifically, but the order of magnitude is big enough that the FastCGI-style persistent pool exists precisely to amortize it away.

FastCGI was invented to fix exactly this. The idea is almost obvious in retrospect: don’t kill the handler after each request. Keep a small pool of handler processes alive, let the web server talk to them over a Unix socket, and frame the requests so you can multiplex cleanly. The web server is the front-end; the handler pool is the back-end; between them flows a stream of length-prefixed records.

text

FastCGI request model (what doesn't die for you)

  client         web server           unix socket          handler pool
    |  HTTP GET    |                       |                    .
    | -----------> | pack record           |                    .
    |              | -------- [len|payload] ------------>       [ already warm ]
    |              |                       |                    [ run handler ]
    |              | <------- [len|payload] ------------        [ sleep ]
    | <--- HTTP -- |                       |                    .

The handlers are long-lived. The interpreter is warm. Your handler’s global state survives between requests (for better and for worse). The transport is a boring socket with framed records. You trade process-per-request isolation for something closer to a call-over-wire. It’s a very good trade and it is essentially what modern Python WSGI servers, PHP-FPM, and — let’s just be honest — AWS Lambda’s warm-start pool are all doing internally.

I did not set out to build a FastCGI. I set out to build a serverless thing where you drop a file and it becomes a route. It turns out that once you want warm starts and polyglot handlers and a filesystem-addressable route tree, the design space funnels you into something that looks uncannily like FastCGI wearing a JSON trenchcoat. More on the trenchcoat later.

Here is the part where the title makes sense.

The gateway — the piece that terminates HTTP, reads the request, figures out which file on disk should handle it, and forwards the call — is the most important and most annoying piece of any FaaS. It has to be fast. It has to hot-reload when you save a file. It has to do routing, auth, cookies, CORS, OpenAPI, and it has to do all of that without becoming a 30 MB Node process with a 400 ms cold start.

I tried writing it in Go. It was fine. It was also a lot of code for what is essentially “take a request, look up a file, open a socket, write, read, write response.” Then one evening I remembered that OpenResty — nginx with Lua embedded — already does the hard parts (HTTP parsing, TLS, epoll, shared memory) and just lets me program the policy layer in a scripting language with sub-millisecond startup. You don’t boot OpenResty per request. OpenResty boots once, at process start, and then your Lua runs inside the request phase hooks. Think of it as the web server inviting your code to live inside its event loop as a guest.

So I introduced Lua to my life. It wasn’t a decision so much as an internal tree of options that kept branching open: every third thing I needed turned out to be “oh, I can just do this in Lua and it works.” Route discovery? Lua walking a directory. Shared-memory routing table? ngx.shared.DICT. Hot reload on file save? A tiny Lua timer that stats the functions directory and rebuilds a route table in a shared dict. Session cookie parsing? Twelve lines:

openresty/lua/fastfn/http/gateway.lua:70-82

local function parse_cookies(cookie_header)
  local cookies = {}
  if type(cookie_header) ~= "string" or cookie_header == "" then
    return cookies
  end
  for pair in cookie_header:gmatch("[^;]+") do
    local k, v = pair:match("^%s*([^=]+)%s*=%s*(.-)%s*$")
    if k and k ~= "" then
      cookies[k] = v or ""
    end
  end
  return cookies
end

Twelve lines. No npm install cookie-parser. No go get github.com/gorilla/sessions. No transitive dependency that was abandoned in 2019 and is now maintained by a bot. Twelve lines of Lua that run inside nginx’s request phase, and the gateway now understands session cookies.

A note on Lua
Lua is a weird language. It is 1-indexed. It has one data structure (the table) and no real module system until you squint. Its standard library is almost aggressively minimal. None of that actually hurts here — the gateway is short, the hot paths stay in LuaJIT, and the things Lua doesn’t have (a huge runtime, a package ecosystem, a complicated type system) are exactly the things you don’t want in a request-hot path inside nginx.

And it is perfect for this job. The runtime is small, the code is small, the latency is small, and the whole gateway — routing, session parsing, request marshalling, response un-marshalling, OpenAPI endpoint, Swagger UI serving — lives in a handful of Lua files under openresty/lua/fastfn/. The http/ subtree has exactly the modules you’d expect from a real gateway: gateway.lua, assets.lua, catalog.lua, openapi_endpoint.lua, swagger_ui.lua, reload.lua. The main gateway.lua is 1341 lines; the entire client.lua that implements the wire protocol is 110 lines. I’ll quote most of it in a minute.

The trick that makes the Lua gateway fast is not clever. It’s that the route table doesn’t live in a database, it doesn’t live in Redis, it doesn’t even live in per-worker memory. It lives in ngx.shared.fn_cache, an nginx shared-memory zone readable by every worker process. When fastfn dev starts, Lua walks the functions directory, builds an index — “GET /hello/functions/get.hello.pypython runtime” — and stuffs it into the shared dict. On file change, a reload Lua chunk rebuilds it. Requests do a single shared-dict lookup in the access_by_lua phase and then dispatch. There is no “framework.” There is a hash table and a convention.

Now the protocol. This is the part where I accidentally rebuilt FastCGI.

When the Lua gateway has decided that GET /hello should be served by the Python runtime, it needs to get the request over to the Python daemon. The Python daemon is a long-lived process listening on a Unix socket (default /tmp/fastfn/fn-python.sock, configurable via FN_PY_SOCKET, which you can see at srv/fn/runtimes/python-daemon.py:25). The gateway opens the socket, writes the request, and reads the response.

The frame format is deliberately boring:

text

  4 bytes            N bytes
  ---------------    ----------------------------------------
  | big-endian  |    |                                      |
  | uint32 len  |    |  JSON payload (request or response)  |
  ---------------    ----------------------------------------

Four bytes of big-endian length. Then that many bytes of JSON. That’s the whole protocol. It’s FastCGI’s record framing, simplified to one record type, with the record body being JSON instead of FastCGI’s key-value binary encoding. If FastCGI wore a trenchcoat and tried to pass as a modern REST API, this is how it would dress.

The Lua side of the wire is small enough to quote in full. Here is the client that sends a request and parses a response:

openresty/lua/fastfn/core/client.lua:22-106

function M.call_unix(socket_uri, req_obj, timeout_ms)
  local payload = cjson.encode(req_obj)
  if not payload then
    return nil, "invalid_request", "failed to encode request"
  end

  local connect_timeout = math.max(50, math.floor(timeout_ms * 0.2))
  local io_timeout = math.max(50, timeout_ms)

  local sock = ngx.socket.tcp()
  sock:settimeouts(connect_timeout, io_timeout, io_timeout)

  local ok, err = sock:connect(socket_uri)
  if not ok then
    if is_timeout(err) then
      return nil, "timeout", "connect timeout"
    end
    return nil, "connect_error", tostring(err)
  end

  local sent, send_err = sock:send(pack_u32(#payload) .. payload)
  if not sent then
    sock:close()
    if is_timeout(send_err) then
      return nil, "timeout", "send timeout"
    end
    return nil, "send_error", tostring(send_err)
  end

  local header, header_err = sock:receive(4)
  if not header then
    sock:close()
    return nil, "receive_error", tostring(header_err)
  end

  local body_len = unpack_u32(header)
  if body_len <= 0 or body_len > 10 * 1024 * 1024 then
    sock:close()
    return nil, "invalid_response", "invalid frame length"
  end

  local body, body_err = sock:receive(body_len)
  sock:close()
  -- decode, validate, return
end

The manual pack_u32 function is a nice reminder that Lua 5.1 (which OpenResty uses) does not ship with string.pack, so I implement it myself:

openresty/lua/fastfn/core/client.lua:5-11

local function pack_u32(n)
  local b1 = math.floor(n / 16777216) % 256
  local b2 = math.floor(n / 65536) % 256
  local b3 = math.floor(n / 256) % 256
  local b4 = n % 256
  return string.char(b1, b2, b3, b4)
end

The frame cap is 10 MB (body_len > 10 * 1024 * 1024). The Python side enforces a symmetric limit via FN_MAX_FRAME_BYTES, which defaults to 2 MB (srv/fn/runtimes/python-daemon.py:26). Yes, the two defaults don’t match. That is on purpose: the gateway is a little more permissive than the daemon so that a misconfigured daemon fails at the daemon, not in a confusing half-read at the gateway. It’s the kind of asymmetry that reads wrong in a code review and reads right when you’re debugging a production incident at 2 AM.

Once you have the wire protocol, the whole request lifecycle is easy to draw. Here is a warm request, with rough timings I measured on a mid-2024 laptop running Linux:

text

  t=0.00 ms   client sends HTTP GET /hello
  t=0.10 ms   nginx accept, parse HTTP headers
  t=0.20 ms   Lua access_by_lua: route lookup in ngx.shared.fn_cache
  t=0.30 ms   Lua: cjson.encode(req_obj) -> JSON payload
  t=0.40 ms   Lua: ngx.socket.tcp():connect("/tmp/fastfn/fn-python.sock")
  t=0.50 ms   Lua: send [4B len][JSON]
  t=0.60 ms   Python daemon: recv 4 bytes, decode length
  t=0.70 ms   Python daemon: recv N bytes, json.loads
  t=0.80 ms   Python daemon: dispatch to handler from _HANDLER_CACHE
  t=1.10 ms   handler(event) returns {"status": 200, "body": "..."}
  t=1.20 ms   Python daemon: json.dumps, send [4B len][JSON]
  t=1.30 ms   Lua: recv header, recv body, cjson.decode
  t=1.40 ms   Lua content_by_lua: ngx.say(resp.body)
  t=1.50 ms   nginx flushes HTTP response to client

Those numbers are rough — I haven’t published an end-to-end benchmark for the pure function path on its own, so take the per-line millisecond breakdown as ballpark, not gospel. The shape is right even if the exact numbers drift. Cold starts are a very different conversation and I’ll get to that.

The wire protocol is language-agnostic on purpose. The Python daemon is one implementation. There is a Node daemon. There is a path to Rust, Go, PHP, and Lua handlers too. Every runtime daemon makes the same promise: open a Unix socket, speak the 4-byte-length + JSON protocol, and when a request comes in, dispatch to a function with the signature that AWS Lambda made famous.

Here is a real handler, from the polyglot tutorial examples:

examples/functions/polyglot-tutorial/step-2/index.py:1-18

import json


def handler(event):
    query = event.get("query") or {}
    name = query.get("name") or "friend"
    return {
        "status": 200,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps(
            {
                "step": 2,
                "message": f"Hello {name} from Python.",
                "runtime": "python",
                "name": name,
            }
        ),
    }

That is the entire contract. def handler(event): return {...}. Lambda-shape without Amazon. Cloudflare-Workers-shape without Cloudflare. It’s also, if you squint, just CGI with the environment-variables-and-stdin replaced by a JSON envelope and a warm socket. The shape has been stable since the 1990s because it’s the right shape: a request is a dict, a response is a dict, and the handler is a pure function from one to the other.

The daemon supports three invoke adapters explicitly (srv/fn/runtimes/python-daemon.py:51-53):

python

_INVOKE_ADAPTER_NATIVE = "native"
_INVOKE_ADAPTER_AWS_LAMBDA = "aws-lambda"
_INVOKE_ADAPTER_CLOUDFLARE_WORKER = "cloudflare-worker"

“native” is my default shape. The other two exist so you can lift an existing Lambda or Cloudflare Worker into fastfn with basically no changes. That interop was not free; it cost me a careful event-shape translator. But it means you can take code you already have running on somebody else’s serverless and run it locally with fastfn dev, which is genuinely useful when your employer’s AWS console is having one of its weeks.

The Python daemon doesn’t just dispatch in the main thread. It has a ThreadPoolExecutor fronting a pool of handler workers, gated by a slot budget. The knobs are all environment variables:

srv/fn/runtimes/python-daemon.py:38-40

RUNTIME_POOL_ACQUIRE_TIMEOUT_MS = int(os.environ.get("FN_PY_POOL_ACQUIRE_TIMEOUT_MS", "5000"))
RUNTIME_POOL_IDLE_TTL_MS = int(os.environ.get("FN_PY_POOL_IDLE_TTL_MS", "300000"))
RUNTIME_POOL_REAPER_INTERVAL_MS = int(os.environ.get("FN_PY_POOL_REAPER_INTERVAL_MS", "2000"))

So: acquire timeout 5 seconds, idle TTL 5 minutes, reaper runs every 2 seconds. These are FastCGI-pool numbers dressed in Python clothing. The reaper thread walks the pool, kills idle workers past their TTL, and trims the footprint. The acquire timeout is the “I’m at capacity, back off” signal.

Chapter 3 was the moment I introduced Lua to my life. What I didn’t appreciate then is how quickly it would colonize every corner of the control plane. I would set out to solve one problem in Lua — “let me parse a cookie” — and by the end of the afternoon the routing table, the OpenAPI generator, the rate limiter, and the admin dashboard were all Lua too, running in the same nginx worker, sharing the same ngx.shared.DICT zones, with no inter-process hop between them.

This is the chapter where I list the places Lua runs. Lua isn’t a secret weapon. It’s a small language with a fast JIT, embedded in a web server that already does the hard parts, and it happens to fit this problem like a key in a lock.

The first place Lua won was routing. The routes don’t live in a YAML file or a config object; they live in the filesystem. core/routes.lua walks the functions directory on boot, reads metadata off each handler file, and builds a catalog of “GET /hello → Python → get.hello.py.” It then caches the catalog in ngx.shared.fn_cache so every worker gets the same view for free. The discovery entry point is just this:

openresty/lua/fastfn/core/routes.lua:2228-2237

function M.discover_functions(force)
  if not force then
    local raw = CACHE:get("catalog:raw")
    if raw then
      local parsed = cjson.decode(raw)
      if parsed then
        return parsed
      end
    end
  end
  -- ... walk functions_root, populate catalog.runtimes / mapped_routes ...
end

core/routes.lua is the big one — about 3,245 lines — because routing is where every concern eventually meets: methods, reserved prefixes, conflicts, source ranks. Next to it sits core/fs.lua (about 392 lines), a small FFI wrapper around stat, opendir, readdir, and friends. It exists because LuaJIT’s FFI lets me skip the LuaFileSystem C module and call libc directly, keeping the OpenResty image lean:

openresty/lua/fastfn/core/fs.lua:263-280

function M.list_dirs_recursive(path, skip_fn)
  local out = {}
  if not M.is_dir(path) then
    return out
  end
  local function walk(dir)
    if type(skip_fn) == "function" and skip_fn(dir) then
      return
    end
    out[#out + 1] = dir
    for _, child in ipairs(M.list_dirs(dir)) do
      walk(child)
    end
  end
  walk(path)
  table.sort(out)
  return out
end

That’s the filesystem walk. Lua-land readdir, with a pluggable skip_fn to prune .git, node_modules, __pycache__, and .fastfn. It returns a sorted list of directories that routes.lua and watchdog.lua both lean on. Which made me think: if the routes are a tree on disk, then the OpenAPI spec is a projection of that same tree, and the dashboard is just a UI over it.

That “projection of the same tree” idea turned into core/openapi.lua, a 1,309-line module that takes the catalog produced by routes.lua and emits an OpenAPI 3 document. There are no decorators on the handler functions. There is no @app.route("/hello"). The handler is just def handler(event): return {...}, and the spec gets generated from the route tree plus a thin layer of per-function metadata (fn.config.json, if the handler bothered to write one).

The HTTP side of this is a 101-line Lua file that wires it all up:

openresty/lua/fastfn/http/openapi_endpoint.lua:92-101

local spec = openapi.build(catalog, {
  server_url = server_url,
  runtime_order = routes_mod.get_runtime_order(),
  include_internal = env_bool("FN_OPENAPI_INCLUDE_INTERNAL", false),
  invoke_meta_lookup = invoke_meta_lookup,
})

ngx.status = 200
ngx.header["Content-Type"] = "application/json"
ngx.say(cjson.encode(spec))

That is the entire /openapi.json endpoint. Reload your handler, hit the URL, the spec reflects it. Swagger UI (http/swagger_ui.lua, 119 lines) serves a static page that points at that JSON and you have interactive docs without running a separate docs server. The OpenAPI generator also leans on core/invoke_rules.lua to normalize per-function invocation policy — which methods are allowed, which routes are reserved:

openresty/lua/fastfn/core/invoke_rules.lua:4-14

M.ALLOWED_METHODS = {
  GET = true,
  POST = true,
  PUT = true,
  PATCH = true,
  DELETE = true,
}
M.RESERVED_ROUTE_PREFIXES = {
  "/_fn",
  "/console",
}

Which made me realize how much of modern API tooling is string-matching and allowlists. The “spec” is not a spec, it’s a view.

The shared-memory trick that makes routing cheap is the same trick that makes per-function concurrency limits cheap. core/limits.lua is 133 lines, most of them boilerplate, and the whole mechanism is an incr/decr dance on an ngx.shared.fn_conc zone:

openresty/lua/fastfn/core/limits.lua:22-39

function M.try_acquire(dict, fn_key, limit)
  if not limit or limit <= 0 then
    return true
  end

  local key = key_for(fn_key)
  local current, err = dict:incr(key, 1, 0)
  if not current then
    return false, "counter_error:" .. tostring(err)
  end

  if current > limit then
    dict:incr(key, -1, 0)
    return false, "busy"
  end

  return true
end

That function is the entire “no more than N in-flight invocations of function X across the whole gateway” primitive. dict:incr is atomic across workers because ngx.shared.DICT is shared memory with a lock underneath. There is no Redis round trip. There is no admission-controller pod. It’s a counter in RAM, and it’s correct because nginx gives it to me correct.

On top of limits sits core/watchdog.lua (299 lines), a Linux inotify watcher written in LuaJIT FFI. It opens inotify_init1(IN_NONBLOCK | IN_CLOEXEC), walks the functions tree, adds a watch on every subdirectory, and schedules a debounced reload whenever a file changes:

openresty/lua/fastfn/core/watchdog.lua:268-284

local ok_poll, poll_err = ngx.timer.every(poll_interval, function(premature)
  if premature then
    return
  end
  local changed, read_err = drain_events()
  -- ...
  if pending_since and (ngx.now() - pending_since) >= debounce_s then
    pending_since = nil
    schedule_reload()
  end
end)

A 150 ms debounce, a shared reload callback, and the routing table rebuilds in place. Save a file, the route is live before I tab back to the browser.

If I can schedule a reload with ngx.timer, I can schedule anything. core/scheduler.lua (1,712 lines) and core/jobs.lua (993 lines) together implement a cron-ish, retry-aware, optionally-persisted job runner that lives inside the nginx worker. No separate cron daemon, no Celery, no RabbitMQ:

openresty/lua/fastfn/core/scheduler.lua:1659-1667

local ok, err = ngx.timer.every(interval, function(premature)
  if premature then
    return
  end
  local ok2, err2 = pcall(tick_once)
  if not ok2 then
    ngx.log(ngx.ERR, "scheduler tick failed: ", tostring(err2))
  end
end)

That single ngx.timer.every is the tick loop. On every tick it walks the active schedules, checks cron expressions against the wall clock, and enqueues runs via ngx.timer.at(0, ...). Retries with exponential backoff fall out of the same primitive. Persistence is a JSON blob written to disk every 15 seconds. It’s a lot of code for a small idea: the event loop I already have is enough of a scheduler for a single-node FaaS.

The scheduler is abstract until you see a function that uses it. The cleanest one in the repo is examples/functions/node/telegram-ai-digest — a Node function that pulls messages from a Telegram group, summarises them with OpenAI, and sends the digest back to a chat. What makes it interesting for this section is the schedule block in its config:

examples/functions/node/telegram-ai-digest/fn.config.json

{
  "timeout_ms": 30000,
  "invoke": {
    "summary": "Telegram AI digest: fetches group messages, summarizes with OpenAI, sends digest",
    "methods": ["GET"],
    "content_type": "application/json"
  },
  "schedule": {
    "enabled": true,
    "every_seconds": 3600,
    "method": "GET"
  }
}

That is the entire “turn this into a cron job” interface: a three-line schedule stanza in the function’s own config, next to the handler. No external crontab, no separate registration step, no YAML pipeline. When fastfn boots, the scheduler reads every function’s fn.config.json, sees this one has a schedule.enabled = true, and adds it to the tick loop with every_seconds = 3600. Every hour, the scheduler issues an internal GET request to the function as if a client had called it — same routing, same handler, same logging — and the function does its work.

The same pattern shows up in the sibling examples: telegram-ai-reply is a webhook (POST handler, no schedule — Telegram calls it when a message arrives), telegram-send is a library-style function you can invoke from other handlers to send a message (dry_run by default, which is the kind of safety detail I learned to add after the second bot that was supposed to be silent). Three functions, three lifecycles — webhook, library call, cron — and all of them are just files in functions/. The only thing that makes the digest “a cron” is those three lines of config.

I like this shape because the “deployment model” of the function and its “invocation model” are in the same place. If a teammate wonders “how does this run?”, they open fn.config.json, they see schedule.every_seconds = 3600, they know. No hunting through a cron file on a server they don’t have ssh access to.

The scheduled digest function is useless without two secrets and one identifier: a Telegram bot token, an OpenAI API key, and the chat ID to post to. This is the point in every FaaS tutorial where you either hand-wave about environment variables or paste a paragraph about Vault. fastfn does neither. It puts the config next to the function, in a sibling file called fn.env.json, with a small amount of structure that makes the meaning of each value explicit:

examples/functions/node/telegram-ai-digest/fn.env.json

{
  "TELEGRAM_BOT_TOKEN": {
    "value": "<set-me>",
    "is_secret": true
  },
  "TELEGRAM_CHAT_ID": {
    "value": "<set-me>",
    "is_secret": false
  },
  "OPENAI_API_KEY": {
    "value": "<set-me>",
    "is_secret": true
  }
}

Two things to notice. First, every key is an object, not a bare string — the value lives at .value, and the metadata lives next to it. Second, that is_secret boolean is load-bearing. The runtime uses it to decide whether a value is masked in logs and in the admin dashboard, whether it can be echoed back through a _fn/ui_state endpoint, and whether the dashboard’s “view” button is allowed to reveal it in cleartext. TELEGRAM_CHAT_ID is not a secret — it’s just a number, and you’ll want to see it in the UI while you’re debugging “why didn’t my digest show up.” TELEGRAM_BOT_TOKEN is — and if you accidentally leak it into logs, Telegram’s reaction is to invalidate the token, so the scheduler would stop working silently until you notice. The is_secret flag is the entire difference between those two outcomes.

The value "<set-me>" is also not a bug — it’s a pattern the repo uses deliberately. The handler in core.js treats <set-me>, set-me, changeme, <changeme>, and replace-me as “unset” sentinels:

examples/functions/node/telegram-ai-digest/core.js:1-8

function isUnsetConfigValue(value) {
  if (value === undefined || value === null) return true;
  const s = String(value).trim();
  if (!s) return true;
  const l = s.toLowerCase();
  return l === "<set-me>" || l === "set-me" || l === "changeme"
      || l === "<changeme>" || l === "replace-me";
}

That means you can check fn.env.json into git with "<set-me>" placeholders, and the function fails clean instead of pretending to run with an empty string. Real deployments replace those placeholders (in the dashboard, through the API, or by editing the file on a controlled host) with actual values; is_secret: true entries get masked immediately on save.

Inside the handler, the runtime delivers these values on event.env, alongside the request-scoped event.method, event.query, etc. The digest’s core.js reads them with a small fallback that prefers the function-local env, falling back to the ambient process.env the daemon whitelisted at startup:

examples/functions/node/telegram-ai-digest/core.js:86-88

const botToken = chooseConfigValue(env.TELEGRAM_BOT_TOKEN, process.env.TELEGRAM_BOT_TOKEN);
const chatId   = chooseConfigValue(env.TELEGRAM_CHAT_ID,   process.env.TELEGRAM_CHAT_ID);
const apiKey   = chooseConfigValue(env.OPENAI_API_KEY,     process.env.OPENAI_API_KEY);

This is the shape of config I keep coming back to: a JSON file next to the handler, one value per secret, an explicit is_secret flag that drives masking in every downstream surface, and a language-level event.env that makes the handler’s dependency on those values trivially greppable. It is, again, not a new idea — it is basically the same shape as Heroku’s config vars or a Kubernetes Secret/ConfigMap pair. But it lives in the same folder as the code that uses it, it’s version-controllable with safe placeholders, and it doesn’t require an external secrets manager to get started.

Put the scheduler, the config, and the handler in one directory, and the Telegram digest goes from “a vague cron job somewhere” to “four files I can read in a minute and hand to a teammate.”

The place Lua surprised me most was the admin console. I did not set out to write a web app in Lua. I assumed I’d eventually wire in a small SPA, probably Vue or Svelte, because “everybody does that.” Then I wrote console/login_endpoint.lua in an afternoon and realized I didn’t need the SPA.

The login endpoint is 95 lines and does everything a login endpoint should do: method check, rate limit in ngx.shared.fn_cache, constant-time comparison, PBKDF2 password verification, session cookie. Here’s the rate-limit slice:

openresty/lua/fastfn/console/login_endpoint.lua:38-46

local client_ip = ngx.var.remote_addr or "unknown"
local rate_store = ngx.shared.fn_cache
if rate_store then
  local fail_count = rate_store:get(login_rate_key(client_ip))
  if LOGIN_MAX_ATTEMPTS > 0
     and type(fail_count) == "number"
     and fail_count >= LOGIN_MAX_ATTEMPTS then
    guard.write_json(429, { error = "too many login attempts, try again later" })
    return
  end
end

Five seconds of shared-dict math and I have a working lockout. console/auth.lua (484 lines) handles the session cookie, PBKDF2 with a minimum of 100,000 iterations, optional secrets-from-file for the admin password, and a 12-hour TTL. console/guard.lua (387 lines) is the middleware that every dashboard endpoint calls first to enforce auth, body limits, CSRF, and write-gates. console/data.lua is the big one — about 2,623 lines — and it’s the backing “service layer” that the dashboard endpoints delegate to: list functions, read a function’s code, set code, list versions, read logs, read schedules, aggregate dashboard metrics.

The endpoints themselves are tiny. The dashboard metrics endpoint is 21 lines:

openresty/lua/fastfn/console/dashboard_endpoint.lua:9-21

local method = ngx.req.get_method()
if method ~= "GET" then
  guard.write_json(405, { error = "method not allowed" })
  return
end

-- Aggregate metrics from shared dicts or external systems
local metrics = console.get_dashboard_metrics()
guard.write_json(200, metrics or {
  invocations = {},
  errors = {},
  latency = {}
})

Around that live functions_endpoint.lua (34 lines), logout_endpoint.lua (15 lines), ui_state_endpoint.lua (50 lines), secrets_endpoint.lua (70 lines), packs.lua (77 lines), ui.lua (66 lines), and the chonkier invoke_endpoint.lua (526 lines) which powers the “invoke this function from the browser” console. There is no separate admin web app. There is no npm run dev. There is no second build system. There is Lua, rendering HTML and JSON out of the same nginx worker that serves the gateway traffic.

I also added core/lua_runtime.lua (399 lines), which lets Lua files in the functions directory be handlers — same handler(event) -> response contract as Python and Node, except there’s no cross-process hop because the handler runs in the same worker. I haven’t published a benchmark for this path, so I won’t put a millisecond number on it. For handlers that need to reach out, core/http_client.lua (356 lines) wraps ngx.socket.tcp with URL parsing, keepalive, and timeouts, so handlers call upstream APIs without pulling luasocket into the image. core/home.lua (234 lines) serves the default landing page at /, and http/function_code.lua / http/function_file_content.lua (50 and 76 lines) round-trip function source between the dashboard editor and disk.

So — is Lua magnificent here? Yes, with receipts. The receipts are:

  • Cold start is essentially free. OpenResty boots once. My Lua code is a set of modules loaded at worker init. The gateway.lua file is 1,341 lines; loading it adds roughly nothing to request latency because the request phase hooks are already JIT-compiled by the time the first request arrives. I don’t have a clean “Lua module load time” number to quote here, so I’m not going to put a millisecond figure on it — but every published hot-path p50 in the repo, across workloads, is in the single-digit milliseconds end-to-end through this Lua gateway, and that’s the only number that matters to a user.
  • Shared memory is free-ish. ngx.shared.DICT is a hash table in nginx, protected by a spinlock. I don’t pay for a Redis container to hold counters.
  • The module per concern is small. Routing-discovery core aside, most of the control-plane modules are in the 100–500 line range. limits.lua is 133 lines. watchdog.lua is 299. login_endpoint.lua is 95. These are reading-distance numbers. I can hold any one of them in my head.

And the costs, because there are costs:

  • Lua 5.1 numeric quirks. OpenResty’s LuaJIT is Lua-5.1-ish with some 5.2/5.3 extensions. There’s no native string.pack, which is why the pack_u32 in Chapter 4 exists. Integer-vs-double confusion bit me twice while writing the rate limiter; ngx.shared.DICT:incr returns numbers that are doubles, and I had to be careful with math.floor on counters I planned to compare against tonumber(env) values.
  • pcall discipline everywhere. An uncaught error in a ngx.timer callback kills the timer silently and logs to error.log. Every timer I write is wrapped in pcall, and the scheduler tick, the watchdog callback, and the persistence writer all follow the same pattern. Skip that discipline and you get a scheduler that mysteriously stops ticking at 3 AM.
  • Everything is a string, until it isn’t. Shared dicts store strings and numbers, not tables, which means JSON encode/decode at the boundary. I lean on cjson.safe so that bad input returns nil, err instead of throwing, and I still get the occasional cannot encode sparse array when a table has numeric holes.

None of these are dealbreakers. They are the price of picking a small language with a fast JIT embedded in a web server, and that trade keeps winning. The whole control plane — routing, OpenAPI, limits, watchdog, scheduler, console, auth — is a handful of files I can read in an afternoon. Most concerns are 20 to 40 KB of readable Lua each. The whole core/ directory is about 304 KB on disk; the whole console/ directory is about 156 KB. That’s less Lua, by a wide margin, than the amount of JavaScript a single modern SPA ships before it hits the first route. And the Lua is the product, not the scaffolding.

Which brings me to the part where I try to pull the lessons out of this pile.

Every FaaS has the same temptation: pass the host’s environment variables into the handler. It’s convenient. It’s also how secrets leak. If your CI/CD pipeline exports AWS_SECRET_ACCESS_KEY for deploy scripts, and your Python daemon inherits that env into every handler, congratulations, you have handed every handler on the box a cloud root credential.

The fix is an allowlist, not a blocklist. I discovered this the boring way: by reading other people’s incident reports and deciding I did not want to write my own. The daemon ships with a conservative list of ambient env keys that handlers can see (srv/fn/runtimes/python-daemon.py:59-103):

python

_ALLOWED_WORKER_ENV_KEYS = {
    "PATH",
    "HOME",
    "USER",
    "LOGNAME",
    # ... language runtimes
    "PYTHONHOME",
    "PYTHONPATH",
    "GOPATH",
    "GOCACHE",
    "CARGO_HOME",
    "RUSTUP_HOME",
    # ... CA bundles
    "SSL_CERT_FILE",
    "SSL_CERT_DIR",
    "REQUESTS_CA_BUNDLE",
    # ... locale
    "LANG",
    "TZ",
}
_ALLOWED_WORKER_ENV_PREFIXES = ("LC_",)

The comment above it says the quiet part out loud: “User-defined secrets should come from fn.env.json or request-scoped event.env, not ambient host env.” The whitelist is the enforcement. Everything not in that set, or starting with LC_, gets scrubbed before the handler runs. It’s security hygiene, not security theater.

This is the kind of rule that sounds obvious and is nonetheless wrong in half the CLIs I’ve ever used. My own CLI gets it right because I got it wrong the first time. From cli/cmd/run.go:64-73:

go

// Resolve hot-reload: flag > env > config > default (true)
hotReload := true
if runHotReload {
    // Explicit --hot-reload flag always wins
    hotReload = true
} else if envVal := os.Getenv("FN_HOT_RELOAD"); envVal != "" {
    hotReload = envVal != "0" && envVal != "false" && envVal != "off" && envVal != "no"
} else if viper.IsSet("hot-reload") {
    hotReload = viper.GetBool("hot-reload")
}

Note the comment on line 64. That’s not there for me — that’s there for the next person who tries to “fix” this by making the config file win over the env. Every config system that has ever inverted this order has caused a production incident in which someone set an environment variable in CI, and then wondered why the container kept reading a stale value from a checked-in file.

No database. No registry. No etcd. The routes are files. The config is fastfn.json. The env is fn.env.json. The function-local overrides are fn.config.json. The dependency state is .fastfn-deps-state.json.

This decision was made out of laziness and turned out to be a feature. A filesystem-first FaaS can be version-controlled, rsynced, snapshotted, and diffed with tools I already have. You do not need a “platform” to inspect it. ls is the admin UI. grep is the debugger. This is the same lesson that inetd learned in 1986: sometimes the control plane should be a text file.

8 Chapter 8: The Moral of the Story (Part 1)

I set out to build “drop a Python file, get a URL.” I ended up rebuilding FastCGI on purpose, with a Lua gateway because OpenResty is genuinely the right tool for this job, a JSON wire protocol because binary record formats are a poor tradeoff for a local-first system in 2026, and a polyglot runtime contract because def handler(event): return {...} is the right abstraction and has been since Lambda shipped.

The thing I did not expect is how many of the design decisions were pre-made for me by history. The fork-per-request cost that killed CGI is the same cost that makes container-per-request serverless feel slow today. FastCGI’s persistent pool + framed socket is the same shape that modern serverless platforms converge on internally. Cloudflare Workers are, in their bones, FastCGI with a v8 isolate instead of a process. AWS Lambda warm-start pools are FastCGI with an API Gateway in front. fastfn — at least in the shape I’ve just described — is FastCGI with JSON frames and an OpenResty gateway.

I’ll close Part 1 on the one regret I do have: I could have started with Lua a year earlier. Every time I wrote “just a little gateway in Go” I was writing a worse version of what nginx already does, with a higher cold start, more code, and less observability. The lesson — and it is not a new lesson, which is the embarrassing part — is that when your problem shape matches an existing, well-loved piece of infrastructure, you should just use that infrastructure. FastCGI told me what the data plane should look like in 1996. OpenResty told me what the control plane should look like in 2010. Lambda told me what the handler contract should look like in 2014. All Part 1 of fastfn did was listen.

There is a second branch of this story that this post deliberately doesn’t cover: what happens when a function isn’t the right shape — when you need a long-lived service like a database, a Flask app, or a Next.js frontend living inside the same gateway. That work is currently in flight on a feature branch and isn’t published yet, so I’m leaving it for Part 2, where the Firecracker microVM isolation, the apps / workloads config, and the vsock peer networking all get the space they need. Functions are Part 1. Services are Part 2.

Keep reading
Part 2 — fastfn Part 2: When a Function Isn’t Enough — picks up where this post ends: services, workloads, Firecracker microVMs, and the vsock networking that lets a function reach a Postgres VM at postgres.internal without ever exposing a port on the host.

Related Content