Configuration
Every environment variable that matters, with the traps marked.
Application
APP_NAME="Félagi"
APP_URL=http://felagi.test
APP_TIMEZONE=UTC
Store in UTC and let the interface do the converting. APP_TIMEZONE is the
application's own clock, not a user preference — autopilot schedules carry their
own timezone precisely so a shared default does not have to be anyone's.
APP_URL appears in password reset links, verification links, signed URLs and the setup command
shown when connecting a machine. Pointing it at the wrong host breaks all of them silently.
The session cookie trap
SESSION_COOKIE=felagi_session
Laravel derives the session cookie name from APP_NAME with Str::snake(), which does not
transliterate. Félagi yields félagi_session, and RFC 6265 requires cookie names to be ASCII
tokens. Browsers drop the Set-Cookie header entirely, the session never persists, and every POST
fails CSRF verification with 419 Page Expired — pointing at CSRF, while the cause is the name
of the application.
config/session.php now derives an ASCII-safe name with Str::slug() and ignores an unusable
SESSION_COOKIE, so the variable is belt-and-braces rather than load-bearing.
curl accepts the invalid name happily. Only a real browser reveals the bug.
Database
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=felagi_db
MySQL 8, because task claiming depends on SELECT … FOR UPDATE SKIP LOCKED. SQLite has no such
clause; concurrency tests skip themselves there rather than pretend.
MySQL also refuses literal defaults on JSON columns, which SQLite accepts. Model-level
$attributes carry those defaults instead.
Sessions, cache and queues
SESSION_DRIVER=redis
CACHE_STORE=redis
QUEUE_CONNECTION=redis
Redis for sessions means migrate:fresh no longer signs everyone out — which matters when the
schema changes several times a day.
Redis for the queue rather than database, because Redis is already required here and a database
queue polls a table that the board is also reading. Redis blocks on a pop instead, which is the
difference between a live update arriving now and arriving on the next poll.
Give the queue a Redis database with maxmemory-policy noeviction. Under allkeys-lru — a
perfectly ordinary cache setting — Redis is free to evict queued jobs when memory runs short, and
those jobs are work the application believes it has already handed off.
The worker is not optional
Broadcasts ride their own queue so a slow job cannot delay the interface:
php artisan queue:work --queue=broadcasts,default
composer dev runs one. In production it needs a supervisor.
With sync, forgetting the worker cost nothing. With a real queue it means the interface stops
updating and nothing says why — so the dashboard checks the backlog and says so itself.
Broadcasting
See Broadcasting.
Daemon protocol
FELAGI_MIN_DAEMON_VERSION=0.2.0
FELAGI_DAEMON_DOWNLOAD_URL=https://github.com/kwhorne/Felagi/releases
Raising this is a breaking change for whoever runs the daemons. They are
refused at the door with a 426 and the download URL, which is the right
failure — a clear message before any work starts, rather than a refusal halfway
through a run. But they will do nothing until they are updated, so it belongs in
a release note rather than a patch.
Raising the minimum version locks out older daemons with a 426 that tells them where to get a
newer one. Both are read from config/felagi.php, along with the heartbeat and poll intervals the
server advertises.
Knowledge base
| Variable | Default | What it does |
|---|---|---|
FELAGI_KNOWLEDGEBASE_PATH |
storage/knowledgebase |
Where article documents are written, one HTML file per article |
The database row is metadata — title, parent, position — and the document is a file. Point this at a shared volume if more than one machine serves the application, or every article will be readable on exactly one of them.
The directory must be writable by the web user and is never served directly: articles go through the application, which knows who is asking and which workspace they are in.
Full behaviour in Knowledge base.
Outgoing webhooks
| Variable | Default | What it does |
|---|---|---|
FELAGI_WEBHOOK_ALLOW_PRIVATE |
false |
Permit endpoints inside the network Félagi runs on. Link-local is refused regardless. |
FELAGI_WEBHOOK_TIMEOUT |
10 |
Seconds before a delivery is abandoned |
FELAGI_WEBHOOK_FAILURES |
12 |
Consecutive failures before a webhook is stopped |
FELAGI_WEBHOOK_KEEP_DAYS |
7 |
How long delivery logs are kept |
A queue worker must handle the webhooks lane or nothing is ever delivered.
Full behaviour in Webhooks.
The API
| Variable | Default | What it does |
|---|---|---|
FELAGI_API_THROTTLE |
300 |
Requests a minute, per token |
Full contract in The API.
The sandbox
Not configured here — it lives in ~/.felagi/config.json on each runtime, because
only the machine knows what its kernel can enforce.
{ "sandbox": "confined" }
confined (default), limits, or off. The effective policy is reported at
registration and shown in Administration → Runtimes. Full behaviour in
the daemon's README.
Deliveries
| Variable | Default | What it does |
|---|---|---|
FELAGI_DELIVERY_SECRET |
unset | The token in the return-path webhook URL. Unset means the endpoint is closed, and answers 404 to everything. |
It moves issues, so it fails closed. See Delivering work.
Daemon rate limits
| Variable | Default | Real traffic |
|---|---|---|
FELAGI_DAEMON_THROTTLE_POLL |
120 |
20/min — a claim every 3 seconds |
FELAGI_DAEMON_THROTTLE_BEAT |
30 |
4/min — a heartbeat every 15 seconds |
FELAGI_DAEMON_THROTTLE_WORK |
600 |
~80/min per running task |
FELAGI_DAEMON_THROTTLE_REGISTER |
20 |
1, at startup |
Per token, per minute. Raising one for a busy installation does not open the door for anybody else. See the protocol.
Two-factor authentication
| Variable | Default | What it does |
|---|---|---|
FELAGI_2FA_REQUIRED |
unset | Unset means required in production, nowhere else. Set it to override that in either direction. |
FELAGI_2FA_GRACE_DAYS |
5 |
Days from when an account begins before it is demanded. 0 demands it immediately. |
TRUSTED_PROXIES |
unset | Required before using the IP allowlist behind a proxy. Without it the address Félagi sees is the proxy's, and allowlisting that exempts the internet. |
The nullable default is the point. (bool) env('FELAGI_2FA_REQUIRED', true) would
make an unset variable mean on, and a developer who then switched it off locally
would be one .env copy away from carrying that into production. Absent means
"decide from the environment", which is the safe thing to forget about.
Full behaviour in Two-factor authentication.
Security headers
App\Http\Middleware\SecurityHeaders sets the policy on every response. Two parts need attention
when things change:
connect-src includes the Reverb origin, derived from configuration. The websocket runs on a
different port and is therefore a different origin — 'self' alone blocks it. Run output arrives
over that socket, so blocking it leaves the pane silent rather than broken.
Cache-Control: no-store is set on every HTML response. Laravel's default of no-cache, private still permits the browser to store the page, so a restored tab can resurrect a form
whose CSRF token belongs to a session that no longer exists. no-store is the only value that
rules that out.
HSTS is only sent in production, so local development over plain HTTP is unaffected.
Local environment (Grove)
The project is developed against Grove, which serves *.test
domains and bundles MySQL and Redis.
grove status # daemon, runtimes, services, and the site for this directory
grove env # a .env snippet wired to Grove's services
One thing to know:
public/hot outlives npm run dev. If the interface suddenly renders unstyled, that file is
pointing at a Vite server that is no longer running. Delete it.