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


## Chapter 1: The Ache, or "Why Is a Hello World Seven Files?"

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.

## Chapter 2: A Brief, Slightly Unfair History of CGI

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

### CGI: the original serverless

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.

```
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: the fix, and the shape I ended up copying

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.

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

## Chapter 3: I Had a Problem… and Introduced Lua to My Life

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:

```lua {title="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.

{{< admonition type="info" title="A note on Lua" open=true >}}
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.
{{< /admonition >}}

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 routing table is a shared dict

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.py` → `python` 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.

## Chapter 4: FastCGI Wearing a JSON Trenchcoat

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:

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

```lua {title="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:

```lua {title="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.

### The request lifecycle, with approximate milliseconds

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:

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

## Chapter 5: Polyglot Runtimes, or "Lambda Without Amazon"

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:

```python {title="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.

### A worker pool inside the daemon

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:

```python {title="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 6: Lua All the Way Down

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.

### Routing and discovery: the file tree *is* the database

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:

```lua {title="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:

```lua {title="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.

### OpenAPI, for free

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:

```lua {title="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:

```lua {title="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.

### Observability and limits: rate, concurrency, health

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:

```lua {title="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:

```lua {title="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.

### In-process scheduling: `ngx.timer.at` as cron

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:

```lua {title="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.*

#### A concrete example: a Telegram AI digest that runs every hour

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`](https://github.com/misaelzapata/fastfn/tree/main/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:

```json {title="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`](https://github.com/misaelzapata/fastfn/tree/main/examples/functions/node/telegram-ai-reply) is a webhook (`POST` handler, no schedule — Telegram calls it when a message arrives), [`telegram-send`](https://github.com/misaelzapata/fastfn/tree/main/examples/functions/node/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.

#### Config and tokens: `fn.env.json` and the `is_secret` flag

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:

```json {title="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:

```javascript {title="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:

```javascript {title="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 dashboard is Lua

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:

```lua {title="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:

```lua {title="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.

### Lua as a runtime, and a very tiny HTTP client

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.

### The honest tradeoffs

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.

## Chapter 7: Lessons I Only Half-Expected

### Whitelist the host env, always

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.

### Config precedence: flag > env > config > default

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.

### Everything lives on the filesystem

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.

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

{{< admonition type="tip" title="Keep reading" open=true >}}
Part 2 — [**fastfn Part 2: When a Function Isn't Enough**]({{< ref "/posts/fastfn-services-when-functions-arent-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.
{{< /admonition >}}

