English · 简体中文
Docker
The production image, the Compose quickstart, and everything about running f3router in a container: persistence, ports, health, and the networking gotchas.
- The image
- Compose quickstart
- Ports & LAN access
- Volumes & persistence
- Healthcheck
- Non-root runtime
- Why better-sqlite3 is compiled from source
- Troubleshooting: container cannot reach providers (#733)
The image
f3router publishes a single production image containing the Express server and the built React dashboard:
docker pull ghcr.io/f3tools/f3router:latest # or pin a release, e.g. :v1.2.3
Published tags: latest (default branch), v*.*.* (git release tags), and sha-<commit>. The image is multi-arch (linux/amd64 + linux/arm64, so it runs on a Raspberry Pi).
Build shape (Dockerfile):
- Three stages on the
node:20-bookworm-slimbase (deps→build→runtime); only already-built artifacts are copied into the runtime stage. - The runtime sets
NODE_ENV=production,PORT=3001, andF3ROUTER_INSTALL_METHOD=docker; the process isnode server/dist/index.js. - A
F3ROUTER_COMMIT_SHAARG/ENV pair sits deliberately after allCOPYlayers: the SHA changes every commit, and placing it higher would invalidate the layer cache for the whole image on each build. - The runtime also copies
desktop/package.json(a 400-byte manifest) so a container install can name its own release version (#703). .dockerignorekeeps secrets and local state out of the build context:.env,.env.*,*.db(+-wal/-shmsidecars),.encryption-key, andserver/datanever enter an image.
Compose quickstart
Prerequisites: Docker, Docker Compose, OpenSSL. macOS/Linux:
git clone https://github.com/f3tools/f3router.git
cd f3router
# Generate an encryption key for at-rest key storage
ENCRYPTION_KEY="$(openssl rand -hex 32)"
printf "ENCRYPTION_KEY=%s\nPORT=3001\n" "$ENCRYPTION_KEY" > .env
docker compose up -d
Windows PowerShell variant and a curl one-liner bootstrap live in docs/en/install/01-install.md#docker-compose. Then follow the logs:
docker compose logs -f f3router
Open http://localhost:3001, add provider keys on the Keys page, and grab your unified API key from that page's header — that key is what your OpenAI SDK points at.
Ports & LAN access
The compose file publishes "${HOST_BIND:-127.0.0.1}:${PORT:-3001}:3001". By default the dashboard/API is reachable only from the machine running Docker; to reach it from other devices (e.g. a Raspberry Pi at http://192.168.1.x:3001):
HOST_BIND=0.0.0.0 docker compose up -d
Only do this on a trusted network — the proxy is single-user and guarded only by the unified API key.
Volumes & persistence
SQLite data lives in the named volume f3router-data, mounted at /app/server/data. Keep the same .env ENCRYPTION_KEY and the same volume when upgrading, because provider keys are encrypted at rest. If your host only persists one specific directory, point the database there with FREEAPI_DB_PATH=/that/path/f3router.db.
The Dockerfile declares no VOLUME for /app/server/data on purpose. Persistence is the deployment's job — the compose file maps the named volume there, and plain docker run takes -v. Declaring it in the image instead would create an ANONYMOUS volume on every container that doesn't override it: PaaS runtimes that build from the Dockerfile (Railway, Render, Coolify, Dokploy, CapRover) would then either refuse the image or silently hand each redeploy a fresh empty volume, and the declaration would also shadow a bind mount made at the same path.
See 02-updates-and-backup.md for backups of this volume.
Healthcheck
Both the Dockerfile and the compose file define the same probe: a Node one-liner fetching http://127.0.0.1:<port>/api/ping inside the container, exiting non-zero unless the response is OK.
| Setting | Value |
|---|---|
| Interval | 30s |
| Timeout | 5s |
| Start period | 15s |
| Retries | 3 |
Non-root runtime
The runtime stage switches to USER node before EXPOSE 3001. The server data directory /app/server/data is created and chowned to node:node during the build so the running user owns it; all copied artifacts carry --chown=node:node.
Why better-sqlite3 is compiled from source
better-sqlite3 is a native module. On slim images without a usable prebuilt binary — notably the linux/arm64 leg under QEMU — node-gyp compiles it from source, which needs Python and a C++ toolchain. The build stages therefore install python3 make g++ via apt, and the runtime image copies the already-compiled node_modules instead, keeping the shipped image free of build tooling. (This toolchain was added by #143 after builds broke without it.)
A related packaging subtlety: npm nests some production packages under the workspace instead of hoisting them (undici lives at server/node_modules/undici). Skipping the server/node_modules copy shipped images where the HTTP(S) proxy dispatcher failed to load and every request silently went direct (#550, fixed in c6589ff).
Troubleshooting: container cannot reach providers (#733)
Providers unreachable from the container, but fine from the host? A container has its own network stack, so two things that work on your machine do not carry over:
- A proxy on
127.0.0.1is not your machine. Inside the container, loopback is the container itself. If you reach providers through a proxy client on the host (Clash, v2rayN, sing-box, a corporate proxy), point f3router at the host instead:PROXY_URL=socks5h://host.docker.internal:7890. The bundleddocker-compose.ymlmapshost.docker.internalto the host gateway viaextra_hosts, so this works on plain Linux Docker as well as Docker Desktop. The proxy also has to accept connections from outside loopback (in Clash,allow-lan: true). More detail in 03-outbound-proxies.md. - An IPv6-only host needs IPv6 enabled in Docker. The default bridge network is IPv4-only, so on a host with no IPv4 route the container cannot reach anything, DNS included. Enable it in
/etc/docker/daemon.jsonwith"ipv6": true,"ip6tables": trueand a"fixed-cidr-v6"range, then restart Docker.
To see which case you are hitting, ask the container directly:
docker compose exec f3router node -e "fetch('https://generativelanguage.googleapis.com/').then(r=>console.log('ok',r.status)).catch(e=>console.log('fail',e.cause?.code||e.message))"