# fastfn Part 2: When a Function Isn't Enough (Services, Workloads, and the docker-compose I Didn't Want to Write)


{{< admonition type="info" title="Part of a series" open=true >}}
This is Part 2 of the fastfn write-up. Part 1 — [**fastfn Part 1: I Had a Problem (and Introduced Lua to My Life)**]({{< ref "/posts/fastfn-lua-to-our-lives" >}}) — covers the function side: the Lua gateway, the JSON wire protocol, the polyglot runtimes. This post picks up where Part 1 ends and walks through services, workloads, and the Firecracker microVM path.
{{< /admonition >}}

## Chapter 1: The Ache (or, Why a Function Cannot Be a Postgres)

At the end of Part 1, fastfn was a happy little thing. You'd drop `get.users.py` on disk, the gateway would map it to a URL, a Python daemon would pick it up, and you'd have an HTTP endpoint without touching a Dockerfile. It was elegant in the way things are elegant when they only solve half your problem.

Because then I tried to build a real app with it.

And the moment you try to build a real app, your request/response primitives stop being enough. You need state. You need a database. You need the admin dashboard your colleague already wrote as a Next.js app that expects to be `next start` on port 3000, not a stateless lambda that boots, computes, and dies. You need a Redis for sessions, or a MinIO for blobs, or — in my case — a fully-formed Flask app that was never going to fit through the pinhole of "one handler per file."

I stared at this for a while. Then I said the thing I'd been trying very hard not to say:

> A function is a great shape for request/response. It is a terrible shape for a Postgres.

Which made me realise something uncomfortable. For every side project I'd ever run, the answer to this ache had been the same file: `docker-compose.yml`. One service is my app, the other is a database, maybe there's a sidecar, and they all talk on an implicit network that compose conjured out of nowhere. It's a beautiful piece of ergonomics, and I use it constantly. But if fastfn was going to be my local platform, and I was going to run `docker-compose up` next to `fastfn dev`, I'd have two URL spaces, two health models, two CORS configurations, two authentication surfaces. The whole reason the gateway existed was to have **one** place that knew about my HTTP. A parallel `compose` would have undone that.

So I had a choice. Either I accept that fastfn handles one slice of my app and something else handles the rest. Or I extend fastfn so the long-lived shapes — the Postgres, the Next.js, the Flask — live behind the same gateway as the functions. Same `fastfn.json`, same health endpoint, same CORS, same auth, one unified HTTP surface.

I did not set out to write docker-compose. This post is about how I wrote half of docker-compose on purpose.

The commit this post is mostly about is `6a54c11` on the `firecracker-simple-images` branch: *"Add simple native image apps and services"*, Mar 31 2026, 2254 insertions across 26 files. That's where the idea of a **workload** got a first-class home in fastfn. Everything after that — the vsock peer networking in `5568b6c`, the keep-hot defaults in `6fd5fec`, the firewall + benchmark matrix in `fd8a6b5` — is an evolution of the same abstraction.

## Chapter 2: The Word "Workload"

The CGI-era word for what I wanted was "daemon." The systemd word is "unit." The Kubernetes words are "Deployment" and "StatefulSet." The Heroku word is "Procfile entry." The docker-compose word is, confusingly, "service." I picked the word **workload** as an umbrella, and then split it in two.

```text
   fastfn.json
   ┌──────────────────────────────────────────────┐
   │                                              │
   │  functions-dir: "functions"                  │
   │                                              │
   │  apps:                ← public HTTP faces    │
   │    admin: { ... routes: ["/admin/*"] }       │
   │                                              │
   │  services:            ← private, injected    │
   │    mysql: { port: 3306, volume: "mysql-data" }│
   │                                              │
   └──────────────────────────────────────────────┘
```

The distinction is small but load-bearing. An **app** is a workload that has a public face: it declares `routes`, and the gateway exposes those routes to the outside world. A **service** is a workload that stays private: other workloads and functions can reach it, but the outside world cannot. A Postgres is a service. A Next.js admin dashboard is an app. In the extreme, the only difference between them is whether the routes field is set and whether the gateway advertises them.

Both live in the same file, and both are validated by the same code path (`cli/internal/workloads/config.go:12-14`):

```go
// cli/internal/workloads/config.go:12-15
type Config struct {
	Apps     []AppSpec     `json:"apps,omitempty"`
	Services []ServiceSpec `json:"services,omitempty"`
}
```

Two sibling lists. Not two worlds. The same normalizer walks both, the same state writer persists both, the same Lua gateway reads both from the same JSON file at request time.

This made me realise something almost trivial but worth saying: once you've got a gateway that knows how to route, giving it one more kind of target is not a new project. It's a new row in a table.

## Chapter 3: The Shape of a Workload

Let's look at the actual shape. Here's the bare minimum fastfn config with one app and one service, taken from the README diff in that commit (`README.md:178-202`):

```json
// fastfn.json
{
  "functions-dir": "functions",
  "public-base-url": "https://api.example.com",
  "openapi-include-internal": false,
  "apps": {
    "admin": {
      "image": "ghcr.io/acme/admin:latest",
      "port": 3000,
      "routes": ["/admin/*"]
    }
  },
  "services": {
    "mysql": {
      "image": "mysql:8.4",
      "port": 3306,
      "volume": "mysql-data"
    }
  }
}
```

Three things are worth staring at. First, the image source. Second, the port. Third, the routes (only on apps). Everything else is an optional knob.

The image source is interesting because there are three valid ways to declare one, and the config code enforces that you pick exactly one of them (`cli/internal/workloads/config.go:383-395`):

```go
// cli/internal/workloads/config.go:387-395
sourceCount := 0
for _, value := range []string{image, imageFile, dockerfile} {
    if strings.TrimSpace(value) != "" {
        sourceCount++
    }
}
if sourceCount != 1 {
    return fmt.Errorf(
        "must set exactly one image source among image, image_file or dockerfile",
    )
}
```

So your three choices are:

- `image`: a registry reference like `mysql:8.4`, or a path to a local Firecracker bundle directory on disk.
- `image_file`: a local OCI or Docker archive, converted to a cached Firecracker bundle on first use.
- `dockerfile`: a path to a Dockerfile that fastfn will build through the Docker Engine API, then likewise convert.

The exactly-one rule is load-bearing. It says "one workload, one source of truth." You do not get to mix a local bundle with a registry image and see which one wins. That way lies debugging you do not want.

The port is simpler: it's the container port the workload listens on, with a validation that it falls inside `1..65535`. The routes, for apps, are an array of URL prefixes. The default form of a route supports both exact (`/admin`) and trailing-glob (`/admin/*`) matching. There's a normalizer that trims trailing slashes and enforces a leading slash (`cli/internal/workloads/config.go:941-953`), so the three ways you might write the route all become one canonical string.

The full AppSpec is larger than this minimum, because once you start running real apps you want knobs. Here's the whole surface (`cli/internal/workloads/config.go:66-88`):

```go
// cli/internal/workloads/config.go:66-88
type AppSpec struct {
    Name          string             `json:"name"`
    ScopeDir      string             `json:"scope_dir,omitempty"`
    Image         string             `json:"image,omitempty"`
    ImageFile     string             `json:"image_file,omitempty"`
    Dockerfile    string             `json:"dockerfile,omitempty"`
    Context       string             `json:"context,omitempty"`
    Port          int                `json:"port"`
    Env           map[string]string  `json:"env,omitempty"`
    Command       []string           `json:"command,omitempty"`
    WorkingDir    string             `json:"working_dir,omitempty"`
    User          string             `json:"user,omitempty"`
    Volume        *VolumeSpec        `json:"volume,omitempty"`
    Volumes       []*VolumeSpec      `json:"volumes,omitempty"`
    Healthcheck   HealthcheckSpec    `json:"healthcheck,omitempty"`
    Routes        []string           `json:"routes,omitempty"`
    Replicas      int                `json:"replicas,omitempty"`
    Ports         []PortSpec         `json:"ports,omitempty"`
    Access        AccessSpec         `json:"access,omitempty"`
    ProcessGroups []ProcessGroupSpec `json:"process_groups,omitempty"`
    HA            *HAConfig          `json:"ha,omitempty"`
    Lifecycle     LifecycleSpec      `json:"lifecycle,omitempty"`
}
```

`ServiceSpec` is almost identical (`config.go:90-111`), minus `Replicas`; for services the process group does replica counting instead. I was tempted, briefly, to unify them into one struct with an `IsPublic bool`. I'm glad I didn't. The two shapes have subtly different validators and subtly different defaults, and trying to fold them into one type kept producing ternaries where a second type produced clarity.

The symmetry hides a subtle asymmetry that shows up in `defaultAppLifecycle` vs `defaultServiceLifecycle` (`config.go:658-672`):

```go
// cli/internal/workloads/config.go:658-672
func defaultAppLifecycle() LifecycleSpec {
    return LifecycleSpec{
        IdleAction:   "run",
        PauseAfterMS: 15000,
        Prewarm:      true,
    }
}

func defaultServiceLifecycle() LifecycleSpec {
    return LifecycleSpec{
        IdleAction:   "run",
        PauseAfterMS: 0,
        Prewarm:      true,
    }
}
```

Apps get a 15-second idle timer they can use if they ever opt into `pause`. Services get zero — because you do not pause a Postgres. A paused Postgres is called "an outage." The default policy everywhere is *stay running, stay hot, and prewarm on boot*. Two lifecycle models, one config file, zero regrets.

## Chapter 4: Three Backends, One Config Shape

Here's where the workload abstraction pays rent. The same JSON stanza is supposed to run in three different ways:

```text
   fastfn.json workload
            │
            ├────────┬────────────┬──────────────────┐
            │        │            │                  │
            ▼        ▼            ▼                  ▼
       docker   native process  Firecracker     (future
       native   (no container)  microVM on       backends)
       (fallback)                Linux/KVM
```

Which one runs is partly a platform decision and partly a branch decision. On this branch (`firecracker-simple-images`), Firecracker is the target on Linux/KVM hosts, and `docker_native.go` is the fallback manager — notice the build tag on the top of the file (`cli/internal/workloads/docker_native.go:1`):

```go
//go:build !linux

package workloads
```

That single line does more work than it looks like. It says "on not-Linux, use the Docker-backed implementation of the workload manager." On Linux the Firecracker manager takes over (`firecracker_manager_linux.go`). Both implementations satisfy the same internal `nativeImageWorkloadManager` interface that `process/runner.go` wires up:

```go
// cli/internal/process/runner.go (from the 6a54c11 diff)
type nativeImageWorkloadManager interface {
    Start(context.Context) error
    Stop(context.Context) error
    StatePath() string
}
```

Three methods. Start brings up all the workloads. Stop brings them down. `StatePath()` tells the rest of the system where the JSON state file lives, so the Lua side can find it. That's it. The Docker manager builds images, creates a network, starts containers, and opens up published ports. The Firecracker manager builds images, converts them into bundles, boots microVMs, and attaches them to a vsock peer network. Different mechanics, identical public contract.

The docker-native manager is, honestly, the docker-compose I refused to write. It creates a network per project (`cli/internal/workloads/docker_native.go:90-98`):

```go
// cli/internal/workloads/docker_native.go:90-98
networkName := "fastfn-" + shortHash(m.cfg.ProjectDir+m.cfg.StatePath)
_, err := m.cli.NetworkCreate(ctx, networkName, dockertypes.NetworkCreate{
    CheckDuplicate: true,
    Driver:         "bridge",
})
if err != nil {
    return fmt.Errorf("create docker network: %w", err)
}
m.networkName = networkName
```

Then for each service and each app it attaches the container with two aliases — the bare name and a `<name>.internal` alias (`docker_native.go:417-423`) — so that apps inside the network can reach `mysql.internal:3306` while the host reaches `127.0.0.1:<published>`. That split is intentional: public apps reach the outside world through the gateway at a stable URL, private services reach each other through the internal alias, and the host reaches published ports for debugging.

A service coming up looks roughly like this:

```go
// cli/internal/workloads/docker_native.go:251-266 (condensed)
service := ServiceState{
    Name:         spec.Name,
    Image:        firstNonEmpty(spec.Image, spec.Dockerfile),
    ImageDigest:  digest,
    Host:         "127.0.0.1",
    Port:         hostPort,
    InternalHost: spec.Name + ".internal",
    InternalPort: spec.Port,
    ContainerID:  containerID,
    Health:       WorkloadHealth{Up: true, Reason: "ok"},
    Volume:       spec.Volume,
    BaseEnv:      cloneEnvMap(spec.Env),
}
service.URL         = BuildServiceURL(spec.Name, service.Host,         service.Port,         spec.Env)
service.InternalURL = BuildServiceURL(spec.Name, service.InternalHost, service.InternalPort, spec.Env)
service.FunctionEnv = BuildFunctionServiceEnv(spec.Name, service, spec.Env)
```

Two observations. First, `BuildServiceURL` inspects the env and figures out the scheme — if it sees `MYSQL_USER` it builds a `mysql://` URL, if it sees `POSTGRES_USER` a `postgres://`, if it sees `REDIS_*` a `redis://`, otherwise falls back to `tcp://` (`state.go:143-157`). It's cheerful URL inference, and it means I don't have to type out `DATABASE_URL` by hand in ninety-nine percent of cases. Second, `FunctionEnv` is the bag of variables that every function in the project will see at request time. That's the bridge: the service is private, but functions get `SERVICE_MYSQL_HOST`, `SERVICE_MYSQL_PORT`, `SERVICE_MYSQL_URL`, plus a direct alias `MYSQL_HOST` / `MYSQL_PORT` / `MYSQL_URL` when the name is unambiguous (`state.go:107-123`, `state.go:193-203`).

Which is, when you squint, exactly what docker-compose does with its `links` and `depends_on` injection. Just with a tighter schema and a sharper naming convention.

## Chapter 5: How Lua Finds a Workload

Now the fun part. The gateway is still OpenResty + Lua — same thing from Part 1. How does a long-lived Go process on the CLI side tell the Lua on the gateway side that workloads exist and are up?

Through a JSON file on disk. That's it.

The CLI's workload manager writes to a state file at a known path. The path gets exported as an environment variable to the gateway (`process/runner.go` in the 6a54c11 diff):

```go
// cli/internal/process/runner.go (from 6a54c11)
if path := strings.TrimSpace(workloadMgr.StatePath()); path != "" {
    baseEnv = append(baseEnv, "FN_IMAGE_WORKLOADS_STATE_PATH="+path)
}
```

On the Lua side, `image_workloads.lua` reads that path on every request:

```lua
-- openresty/lua/fastfn/core/image_workloads.lua:22-48
local function state_path()
  local path = tostring(os.getenv("FN_IMAGE_WORKLOADS_STATE_PATH") or "")
  if path == "" then
    return nil
  end
  return path
end

local function load_state()
  local path = state_path()
  if not path then
    return { apps = {}, services = {} }
  end
  local parsed = read_json_file(path)
  if type(parsed) ~= "table" then
    return { apps = {}, services = {} }
  end
  parsed.apps     = type(parsed.apps)     == "table" and parsed.apps     or {}
  parsed.services = type(parsed.services) == "table" and parsed.services or {}
  return parsed
end
```

Per-request JSON read. In production you'd cache this for some small TTL; in dev it's exactly the right thing. The health status, the routes, the internal hostnames, the lifecycle state — all in that file.

Routing is the next step. The gateway already does a long match dance for functions (covered in Part 1). For workloads, it asks `image_workloads.lua` for candidates whose routes prefix-match the request path, then scores them by route length (longer routes win, because `/admin/api/v1/users` is more specific than `/admin/*`):

```lua
-- openresty/lua/fastfn/core/image_workloads.lua:123-151
function M.public_http_candidates(request_path)
  local state = load_state()
  local candidates = {}

  for _, kind in ipairs({ "apps", "services" }) do
    for _, workload_name in ipairs(sorted_keys(state[kind])) do
      local workload = state[kind][workload_name]
      for _, endpoint in ipairs(workload_public_endpoints(workload)) do
        if tostring(endpoint.protocol or "http") == "http" then
          local routes = type(endpoint.routes) == "table" and endpoint.routes or {}
          for _, route in ipairs(routes) do
            if route_matches(route, request_path) then
              candidates[#candidates + 1] = {
                kind = kind == "apps" and "app" or "service",
                name = workload_name,
                workload = workload,
                endpoint = endpoint,
                route = route,
                route_length = #tostring(route),
              }
            end
          end
        end
      end
    end
  end

  return candidates
end
```

Two details I like. The outer loop iterates `{"apps","services"}` rather than concatenating them, which preserves the priority of apps over services when the same route is somehow claimed twice (which it shouldn't be, but defensive code is cheap). And `route_length` rides along with each candidate so the caller can pick the longest match, which matches the way every sane router resolves overlapping prefixes.

The gateway consumes this list at request time and proxies to the winning endpoint (`openresty/lua/fastfn/http/gateway.lua:950-969`):

```lua
-- openresty/lua/fastfn/http/gateway.lua:950-969
local request_host, request_authority = request_host_values()
local matched_workload, matched_endpoint, workload_access_err = match_public_workload(
  image_workloads.public_http_candidates(request_uri),
  request_host,
  request_authority,
  request_client_ip()
)
if type(matched_workload) == "table" then
  local app_resp, app_err = execute_public_workload_proxy(matched_workload, matched_endpoint)
  if not app_resp then
    local status = 502
    if tostring(app_err or ""):find("unavailable", 1, true) then
      status = 503
    end
    write_response(status, { ["Content-Type"] = "application/json" },
                   json_error("public workload proxy failed: " .. tostring(app_err)))
    return
  end
  write_response(app_resp.status or 502, app_resp.headers or {}, app_resp.body or "")
  return
end
```

And here's the actual proxy — HTTP in, HTTP out, with the hop-by-hop headers stripped on the way back so nothing confuses the client (`gateway.lua:487-537`):

```lua
-- openresty/lua/fastfn/http/gateway.lua:510-536
local resp, err = http_client.request({
  url = string.format("http://%s:%d%s", host, port, ngx.var.request_uri or "/"),
  method = ngx.req.get_method(),
  headers = sanitize_app_request_headers(ngx.req.get_headers()),
  body = body,
  timeout_ms = 30000,
  max_body_bytes = 10 * 1024 * 1024,
})
if not resp then
  return nil, err
end

local filtered = {}
local drop = {
  ["connection"] = true,
  ["keep-alive"] = true,
  ["transfer-encoding"] = true,
  ["content-length"] = true,
  ["upgrade"] = true,
}
for k, v in pairs(resp.headers or {}) do
  if not drop[tostring(k):lower()] then
    filtered[k] = v
  end
end
resp.headers = filtered
return resp
```

Fifteen megabytes of max body. Thirty-second timeout. Hop-by-hop headers dropped. This is the most boring HTTP reverse proxy you've ever read, which is exactly what you want a gateway to be.

The clever bit isn't in the proxy. The clever bit is that *the same ngx phase that resolves functions now also resolves workloads*, with a known priority, from the same JSON state file, using the same host/CIDR firewall rules. The whole thing gets to be one unified HTTP surface.

## Chapter 6: Lifecycle (or, Turning a Table Into a State Machine)

Once workloads exist, they have to go through a life. Starting. Healthy. Unhealthy. Stopped. Paused. Resumed. The lifecycle keeps the operational story honest.

```text
    ┌────────────┐   start()   ┌─────────────┐   healthcheck ok   ┌─────────┐
    │  declared  │ ──────────▶ │  starting   │ ─────────────────▶ │ healthy │
    └────────────┘             └─────────────┘                    └────┬────┘
                                     │                                 │
                                     │ healthcheck fails               │ monitor tick
                                     ▼                                 │ reports failure
                               ┌─────────────┐                         ▼
                               │  unhealthy  │ ◀──────────────── ┌─────────┐
                               └──────┬──────┘                    │ flapping│
                                      │ stop()                   └─────────┘
                                      ▼
                                ┌───────────┐
                                │  stopped  │
                                └───────────┘
```

In state.go, the pieces live in a small cluster of types (`state.go:23-91`):

```go
// cli/internal/workloads/state.go:23-38
type WorkloadHealth struct {
    Up     bool   `json:"up"`
    Reason string `json:"reason,omitempty"`
}

type PublicEndpointState struct {
    Name          string   `json:"name"`
    Protocol      string   `json:"protocol,omitempty"`
    Host          string   `json:"host,omitempty"`
    Port          int      `json:"port,omitempty"`
    ContainerPort int      `json:"container_port,omitempty"`
    ListenPort    int      `json:"listen_port,omitempty"`
    Routes        []string `json:"routes,omitempty"`
    AllowHosts    []string `json:"allow_hosts,omitempty"`
    AllowCIDRs    []string `json:"allow_cidrs,omitempty"`
}
```

The health signal is a two-field struct — up, and a reason when it isn't. Every workload keeps one. The docker-native manager runs a monitor goroutine that wakes every two seconds, inspects each container, and updates the state file when anything changed (`docker_native.go:174-211`):

```go
// cli/internal/workloads/docker_native.go:174-211 (condensed)
func (m *NativeManager) monitor() {
    defer close(m.doneCh)
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-m.stopCh:
            return
        case <-ticker.C:
            changed := false
            for _, item := range m.containers {
                health := m.inspectHealth(item)
                // ... update m.state.Apps or m.state.Services,
                //     setting changed = true when different
            }
            if changed {
                _ = m.writeState()
            }
        }
    }
}
```

The write-on-change behaviour matters. The Lua side reads that state file on every request. If the Go side spammed the file every two seconds unconditionally, every request would see a fresh file and every small fs read would be wasted. Writing only when something *actually moved* keeps the file stable for long stretches, which is the kind of thing you want out of an IPC mechanism that is, charitably, a JSON file.

Startup is serial on purpose: services come up first, then apps (`docker_native.go:100-128`). Apps boot with the service env already populated in their environment — which is the moment where the clean symmetry of the config file asserts itself as a dependency order. Services exist so apps can use them; therefore services boot first.

The CLI entry point is the same boring two-liner in both `dev` and `run`. For `dev`, from the 6a54c11 diff:

```go
// cli/cmd/dev.go (from 6a54c11 diff)
imageWorkloads, hasImageWorkloads, err := configuredImageWorkloads()
if err != nil {
    devFatalf("Invalid apps/services config: %v", err)
    return
}
// ...
if err := runNative(configuredProjectRoot(), absPath, imageWorkloads); err != nil {
    devFatalf("Native dev failed: %v", err)
    return
}
// ...
if hasImageWorkloads {
    devFatal("apps/services are only supported in native mode for this branch; rerun with --native")
    return
}
```

That last branch is important. Apps and services only work with `--native` on this branch; classic Docker dev mode is still functions-only. That's a known limitation, documented explicitly in the config reference (`docs/en/reference/fastfn-config.md:13`). Mixing the two lifecycles in the old docker-backed gateway path was the kind of yak-shave I decided to postpone. Native mode is the one honest path forward for this feature.

The `configuredImageWorkloads` helper is the glue. It reads the `apps` and `services` keys out of viper and normalizes them through `workloads.NormalizeAppSpecs` / `NormalizeServiceSpecs` (`cli/cmd/root.go:171-186`):

```go
// cli/cmd/root.go:171-186 (from 6a54c11 diff)
func configuredImageWorkloads() (workloads.Config, bool, error) {
    var cfg workloads.Config

    apps, appsSet, err := workloads.NormalizeAppSpecs(viper.Get("apps"))
    if err != nil {
        return cfg, false, err
    }
    services, servicesSet, err := workloads.NormalizeServiceSpecs(viper.Get("services"))
    if err != nil {
        return cfg, false, err
    }
    cfg.Apps = apps
    cfg.Services = services
    return cfg, appsSet || servicesSet, nil
}
```

That's the whole wiring. Viper reads `fastfn.json`, the workload package normalizes it, and the native runner spins up a manager whose `StatePath()` gets stamped into the environment of every runtime daemon and every OpenResty worker.

## Chapter 7: The Firecracker Turn

At this point everything I've described works on a laptop with Docker, and nothing I've described needs Firecracker. But the whole branch is called `firecracker-simple-images`, so let me tell you where Firecracker comes in, and what actually changed.

On a Linux/KVM host, the manager isn't `docker_native.go`. It's `firecracker_manager_linux.go`. Same interface. Same state file. Very different mechanics underneath: the OCI image gets converted into a Firecracker bundle (a kernel `vmlinux` plus a `rootfs.ext4`), the bundle is cached under `.fastfn/firecracker/images/`, and a microVM boots with a minimal kernel config. The public contract is identical: the workload listens on its declared port, the gateway proxies to it, the state file reports health.

What's different — and this is where commit `5568b6c` ("Add vsock peer networking for Firecracker workloads") matters — is how the gateway actually *reaches* a Firecracker guest. A regular Docker container publishes a port on a bridge and you dial `127.0.0.1:<hostport>`. A Firecracker microVM has no such convenience by default. So the commit introduces a vsock-based peer network: a guest-side helper (`cli/internal/firecrackerguest/main.go`, 455 lines in that commit) that terminates vsock, and a host-side `private_network.go` that stitches the pieces together. The gateway still dials an `InternalHost:InternalPort`; the plumbing under that host/port is vsock instead of TCP on a bridge. The abstraction holds.

The follow-up commit `6fd5fec` ("Keep Firecracker image workloads hot by default") is the one that made this useful in practice. Without it, a freshly-booted Firecracker workload would feel great on the first request and less great if it ever got paused. With the keep-hot defaults, the workload stays resident and is prewarmed on `fastfn dev` / `fastfn run` startup. The docs call this "speed-first" (`docs/en/reference/fastfn-config.md:302-307`): `idle_action` defaults to `run`, `prewarm` defaults to `true`, for both apps and services.

The real numbers — and these are copy-pasted from the benchmark matrix in `docs/en/explanation/performance-benchmarks.md` — are the only reason I'm willing to call any of this done:

| Case                                     | Source                                    | Build/Pull | First OK  | Hot p50   | Hot p95   | Hot p99   | Same PID |
| ---------------------------------------- | ----------------------------------------- | ---------: | --------: | --------: | --------: | --------: | -------- |
| Flask (`flask-compose`)                  | Dockerfile repo                           | `1168ms`   | `5017ms`  | `1.94ms`  | `3.05ms`  | `4.10ms`  | `true`   |
| Registry app (`traefik/whoami:v1.10.2`)  | Registry image                            | `98ms`     | `2508ms`  | `1.26ms`  | `2.09ms`  | `2.28ms`  | `true`   |
| FastAPI + Postgres (`fastapi-realworld`) | Dockerfile repo + private service         | `1202ms`   | `17036ms` | `5.29ms`  | `7.02ms`  | `7.94ms`  | `true`   |
| Two equal `postgres:16` services         | Same OCI, same native `5432`              | `1246ms`   | `22090ms` | `10.92ms` | `28.85ms` | `32.58ms` | `true`   |
| Rust + Postgres (`rust-postgres`)        | Dockerfile repo + private service         | `35139ms`  | `47602ms` | `2.66ms`  | `3.86ms`  | `10.27ms` | `true`   |

Five rows of a twenty-case matrix (snapshot of April 1, 2026; full list in `docs/en/explanation/performance-benchmarks.md:46-54`). The read I do on this table is:

- Cold build + prewarm is seconds, sometimes tens of seconds for a Rust build. That is not free. But it happens once.
- After prewarm, the hot path is low single-digit milliseconds for lightweight apps and still single-to-low-double-digit for DB-backed ones.
- `same_firecracker_pid = true` in every row, which means the hot loop really is reusing the same resident microVM. The gateway is not quietly respawning Firecracker between requests.
- Two identical `postgres:16` services can share the same native 5432 as long as their workload *names* differ. The private network is per-workload; the port is per-process inside its own guest.

The honest caveat — which the doc itself owns, and I'm happy to repeat (`docs/en/explanation/performance-benchmarks.md:94-139`) — is that the 20-case matrix is not "twenty upstream apps with zero edits." Some cases are upstream-as-is. Some have a benchmark overlay on top. All share the same FastFN runtime path, but the harness is not a no-touch benchmark for all of them. I'd rather say that out loud than varnish it.

And yes, `fd8a6b5` ("Add image workload firewall and benchmark matrix") is the commit that brought both the `allow_hosts` / `allow_cidrs` access control on public ports and the tooling that produced these numbers. I'll cover the firewall in a later post — it's a whole aesthetic in itself — but the short version is: a public app can be locked to a host allowlist and/or a CIDR allowlist, both shown in the gateway score function I quoted in Chapter 5.

## Chapter 8: Functions Meet Services (the Injection Story)

Back to a detail I glossed over. How exactly does a function see a service?

Through the env. When a service boots, the manager calls `BuildFunctionServiceEnv` and stashes the result on the service state (`state.go:107-123`):

```go
// cli/internal/workloads/state.go:107-123
func BuildFunctionServiceEnv(serviceName string, service ServiceState, baseEnv map[string]string) map[string]string {
    out := map[string]string{}
    appendScopedServiceEnv(out, serviceName, baseEnv)

    upper := serviceEnvToken(serviceName)
    out["SERVICE_"+upper+"_HOST"]         = service.Host
    out["SERVICE_"+upper+"_PORT"]         = fmt.Sprintf("%d", service.Port)
    out["SERVICE_"+upper+"_URL"]          = service.URL
    out["SERVICE_"+upper+"_INTERNAL_HOST"] = service.InternalHost
    out["SERVICE_"+upper+"_INTERNAL_PORT"] = fmt.Sprintf("%d", service.InternalPort)
    if strings.TrimSpace(service.InternalURL) != "" {
        out["SERVICE_"+upper+"_INTERNAL_URL"] = service.InternalURL
    }
    appendDirectServiceAlias(out, serviceName, service.Host, service.Port, service.URL)

    return out
}
```

On the Lua side, when a function is about to be invoked, the gateway pulls the union of all services' FunctionEnv into the event envelope it passes to the runtime daemon (`openresty/lua/fastfn/http/gateway.lua:1169-1174`):

```lua
-- openresty/lua/fastfn/http/gateway.lua:1171-1174
local service_env = image_workloads.function_env()
if next(service_env) ~= nil then
  event.env = service_env
end
```

Which means a Python function can just do:

```python
# a handler somewhere in functions/
import os
host = os.environ["SERVICE_MYSQL_HOST"]
port = int(os.environ["SERVICE_MYSQL_PORT"])
url  = os.environ["SERVICE_MYSQL_URL"]
```

…and it works, whether the backing MySQL is a Docker container on macOS or a Firecracker microVM on Linux. Same variable names, same URL scheme inference, same code. The backend change is invisible.

There's a second pathway too. An **app** needs the service env at process startup, not at request time — because a Next.js app reads `process.env` at `next start`, not per request. So the docker-native manager builds an appServiceEnv map from all services and passes it as the container env of every app (`docker_native.go:110-122`, `282-294`). The app therefore sees the same `SERVICE_*` variables, but scoped through `BuildAppServiceEnv` rather than `BuildFunctionServiceEnv` — the difference is whether you use the internal hostname (for apps, which live on the same private network) or the public host (for functions, which live on the host).

```text
                        service discovery fan-out
                        ────────────────────────

       ┌──────────────┐      BuildFunctionServiceEnv
       │   service    │ ───────────────────────────▶  event.env
       │  (e.g. mysql)│                               │
       └──────┬───────┘                               ▼
              │                                ┌─────────────┐
              │                                │  function   │
              │      BuildAppServiceEnv        └─────────────┘
              └──────────────────────▶
                                        container.Env
                                        │
                                        ▼
                                  ┌──────────┐
                                  │   app    │
                                  └──────────┘
```

Two consumers. One source of truth. No hand-coded `DATABASE_URL` in sight.

## Chapter 9: The Lessons I'm Actually Taking Away

Looking back at the 2254-line diff, here's what I'd tell past me before I started.

**Unifying the gateway is the whole point.** The temptation to split HTTP into "fastfn handles the function URLs" and "docker-compose handles everything else" is enormous because it's the path of least resistance. But every split of the HTTP surface is a future bug in CORS, auth, observability, or OpenAPI. Keep the gateway single. Give it more kinds of targets, not more friends.

**Two lifecycle models can coexist if one config file does the compiling.** Functions are request-scoped. Workloads are long-lived. These are genuinely different shapes with different failure modes. Hiding them both behind one `fastfn.json` worked because the config layer compiles both shapes down to the same "thing-the-gateway-proxies-to" runtime representation. Two lifecycle models, one config file, zero regrets.

**Declare the image source exactly once.** The exactly-one rule on `image`/`image_file`/`dockerfile` looks pedantic on day one and saves you from unreadable error messages on day two hundred. You want there to be one way that an engineer looking at `fastfn.json` can tell where this workload is coming from.

**A JSON file is a fine IPC surface between your CLI and your gateway, if you're careful.** Writing only on change, reading per request, and exporting the path through an env var is — against my priors — an extremely calm way to move information between a long-lived Go process and a long-lived OpenResty process. There is a future where this becomes a unix socket and a subscription model. But for now, the file is honest and easy to debug with `cat`.

**Name services after what they are.** I almost didn't auto-generate `MYSQL_HOST` / `MYSQL_URL` aliases alongside the `SERVICE_<NAME>_HOST` ones. Then I wrote my first real handler and remembered that humans do not want to type `SERVICE_MYSQL_HOST`. Direct aliases are a usability feature pretending to be a naming convention.

**Benchmarks keep you honest.** The matrix table above is the only reason I believe the words "resident" and "hot" in my own docs. `same_firecracker_pid = true` means no one is secretly restarting the VM between my request and my p95. That check exists because early on, it *wasn't* true, and the benchmark was the only thing that told me. Measure the property, not the intent.

**And a word about hedges.** There's a lot I deliberately did not do on this branch. Apps and services only work with `--native`. Firecracker workloads only work on Linux/KVM. macOS and Windows users get the Docker manager instead of Firecracker for now, and that path doesn't run image workloads at all outside of native mode on this branch. Rolling updates, blue/green, autoscaling — all absent, and fine to be absent, because the point of this phase was to nail the abstraction, not the operations. A workload manager that does three backends cleanly is worth much more than a workload manager that does one backend with every feature Kubernetes ever shipped.

What's next? There are two directions I can see clearly. One is to bring the workload path to Docker-mode `fastfn dev` so macOS users get the same ergonomics without Firecracker. The other is to make the firewall richer — more than `allow_hosts` and `allow_cidrs`, into something that can express "this service is only reachable from these specific workloads on this private network." Both of those feel like genuinely new chapters, not footnotes.

For now, the headline is small and boring and correct: if you need a Postgres next to your functions, you edit your `fastfn.json`, you add a `services.postgres` stanza, and your functions get `SERVICE_POSTGRES_URL` in their environment. That's it.

Functions are a great shape for request/response. Workloads are the shape for everything that isn't. And now they live in the same config file, the same gateway, the same health endpoint, the same benchmark matrix, with the same keep-hot defaults.

I did not want to write docker-compose. I ended up writing something that rhymes with the tenth of docker-compose I actually use, and nothing else. Which, if I'm being honest, is probably the first time in my career I've built *less* platform than I thought I would.

See you in Part 3, where I'll take the firewall story, the vsock plumbing, and the keep-hot defaults apart in detail. Until then: one file, one route, one workload, one gateway.

