What you need
- Docker Engine 24 or newer, and Docker Compose 2.24 or newer (
docker compose version). - A POSIX shell,
curl, andid. Secrets are created inside the API image, so the host does not needopenssl,python3, orgit.gitis only for building from source. - Free ports 8080 (site and API), 8081 (live WebSocket), and 1883 (MQTT). HTTPS also uses 80 and 443.
- About 1 GB of free RAM and 4 GB of free disk. An idle stack used about 140 MB of RAM. The images are a little over 1 GB.
Images: fizzlayer/fizzlayer-api and fizzlayer/fizzlayer-worker. The same tags are on ghcr.io/fizzlayer/. latest tracks the main branch.
Quick start
Create an empty directory. Download the compose file, then let the API image write .env. You do not clone the repository and you do not compile anything.
mkdir fizzlayer && cd fizzlayer
curl -fsSL https://raw.githubusercontent.com/fizzlayer/fizzlayer/main/deploy/compose.yml -o compose.yml
curl -fsSL https://raw.githubusercontent.com/fizzlayer/fizzlayer/main/deploy/compose.env.example -o .env.example
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/out" fizzlayer/fizzlayer-api:latest init --out /out/.env
docker compose up -d
That docker run writes .env (mode 600) with a database password, a JWT secret, a service-role key, and CRON_SECRET. .env.example lists the same variables with placeholders. It will not overwrite .env. Add init --force to replace it, which breaks a database that already used the old password unless you delete the volume too. scripts/selfhost-init.sh runs the same command.
Open http://localhost:8080. Then:
curl -fsS http://localhost:8080/api/status
curl -fsS http://localhost:8081/health
You want "database":"connected" and a worker health of ok. docker compose down stops the stack and keeps the database. down -v deletes it. The full variable list is in the repository guide.
Build from source
Clone the repository to change the server. ./scripts/selfhost-init.sh then writes deploy/.env. The first build takes several minutes.
git clone https://github.com/fizzlayer/fizzlayer.git
cd fizzlayer
./scripts/selfhost-init.sh
docker compose -f deploy/docker-compose.yml up -d --build
That tags them fizzlayer/fizzlayer-api and fizzlayer/fizzlayer-worker. Later up commands reuse those local images. Pin a published commit with FIZZ_API_IMAGE and FIZZ_WORKER_IMAGE set to fizzlayer/fizzlayer-api:sha-… (the full 40-character commit) in deploy/.env.
First account
Open http://localhost:8080/app.html. Create an account with a password of at least 12 characters. No confirmation email is sent: MAILER_AUTOCONFIRM=true is the default. Sign in later on the same page.
From a terminal:
curl -fsS -c cookies.txt -H 'Content-Type: application/json' \
-d '{"action":"sign_up","email":"you@example.com","password":"choose-a-long-pass"}' \
http://localhost:8080/api/auth
curl -fsS -b cookies.txt -c cookies.txt -H 'Content-Type: application/json' \
-d '{"action":"sign_in","email":"you@example.com","password":"choose-a-long-pass"}' \
http://localhost:8080/api/auth
curl -fsS -b cookies.txt http://localhost:8080/api/me
A reply of "confirm_email":true means autoconfirm is off. Anyone who can open the site can register. To close registration after your own account exists:
docker compose exec db \
psql -U postgres -d postgres \
-c "update public.fizz_instance_settings set sign_up_open = false;"
Send a reading
Create an API sensor. The response includes sensor.id and a one-time api_key that starts with fizz_.
curl -fsS -b cookies.txt -H 'Content-Type: application/json' \
-d '{"kind":"temperature","name":"Bench","mode":"api"}' \
http://localhost:8080/api/sensors
curl -fsS -H "Authorization: Bearer fizz_YOUR_KEY" -H 'Content-Type: application/json' \
-d '{"sensor_id":"SENSOR_UUID","event_id":"reading-1","metrics":{"temperature_c":21.5}}' \
http://localhost:8080/api/ingest
curl -fsS -b cookies.txt \
"http://localhost:8080/api/readings?sensor_id=SENSOR_UUID&limit=20"
HTTP 202 with accepted means the reading was stored. Sending the same event_id again does not create a second row.
Ask where live clients should connect:
curl -fsS http://localhost:8080/api/stream
This stack returns ws://localhost:8081/v1/stream and mqtt://localhost:1883. MQTT 3.1.1 uses the sensor id as the username and the sensor key as the password. The topic is fizz/v1/<sensor-id>/readings.
mosquitto_pub -h 127.0.0.1 -p 1883 \
-u SENSOR_UUID -P 'fizz_YOUR_KEY' \
-t 'fizz/v1/SENSOR_UUID/readings' \
-m '{"event_id":"mqtt-1","metrics":{"temperature_c":23.5}}'
A device WebSocket sends {"op":"auth","key":"fizz_YOUR_KEY"} and then {"op":"reading","sensor_id":"SENSOR_UUID","event_id":"ws-1","metrics":{"temperature_c":22.5}}. The CLI in the next section does that.
Pair the CLI
Install fizz and pair it with this server. The installer downloads the CLI from GitHub. FIZZ_API selects your machine.
curl -fsSL http://localhost:8080/install.sh | sh
FIZZ_API=http://localhost:8080 fizz pair
The terminal prints a short code and waits. Open the Agents page, enter the code, then answer y on the device when it asks you to approve. After that:
fizz send temperature_c 21.5
fizz send --stream temperature_c 22.5
fizz send --mqtt temperature_c 23.5
fizz tail
fizz send uses HTTPS. --stream uses the WebSocket. --mqtt publishes to port 1883. Use only one of those flags. fizz run --once sends CPU, load, memory, and disk where the operating system exposes them.
HTTPS and a domain
Add tls to COMPOSE_PROFILES and set the public name. Ports 80 and 443 must reach this machine. Let's Encrypt uses port 80.
COMPOSE_PROFILES=local,tls
FIZZ_DOMAIN=sensors.example.com
FIZZ_ACME_EMAIL=you@example.com
PUBLIC_URL=https://sensors.example.com
SUPABASE_PUBLIC_URL=https://sensors.example.com
FIZZ_PUBLIC_HOST=sensors.example.com
FIZZ_PUBLIC_SCHEME=https
FIZZ_STREAM_URL=https://sensors.example.com/live
FIZZ_STREAM_ORIGINS=https://sensors.example.com
FIZZ_MQTT_TCP_URL=mqtt://sensors.example.com:1883
docker compose up -d
curl -fsS https://sensors.example.com/api/status
FIZZ_ACME_EMAIL=internal makes a local certificate instead. Try that with curl -kfsS https://localhost/api/status. Keep using http://localhost:8080 in a browser unless you set FIZZ_PUBLIC_SCHEME=https. MQTT on 1883 is not wrapped in TLS.
Supabase Cloud
Apply the SQL yourself, then do not start the local database:
supabase link --project-ref YOUR_PROJECT_REF
supabase db push --linked
COMPOSE_PROFILES=
SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
SUPABASE_PUBLIC_URL=https://YOUR_PROJECT_REF.supabase.co
SUPABASE_SERVICE_ROLE_KEY=YOUR_SERVICE_ROLE_KEY
FIZZ_ALLOW_INSECURE_HTTP=0
FIZZ_AUTH_UPSTREAM=
docker compose up -d api worker
SMTP and GitHub or Google sign-in are configured in the Supabase dashboard in this mode. Pulling a new checkout does not migrate a Cloud database. Run supabase db push --linked again.
Chat, calls, and billing
Leave the keys empty and the site still stores readings.
- Chat. Signed-in chat returns 503 and names
OPENAI_BASE_URLorAI_GATEWAY_API_KEY. For Ollama on the Docker host:OPENAI_BASE_URL=http://host.docker.internal:11434/v1andOPENAI_MODELset to a model that accepts JSON mode.host.docker.internalis mapped to the host. - Voice.
GET /api/voiceis{"available":false}untilELEVENLABS_API_KEYis set. - Calls.
AGENTPHONE_API_KEYplaces US voice calls. There is no self-hosted dialer. Hobbyist plans do not place calls. - Stripe. Set all four of
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PRICE_TINKERER, andSTRIPE_PRICE_PRO, or none. Unset, sign-up hides the plan picker, skips checkout, and opens the dashboard. Billing says paid plans are not available on this server.
Restart after editing .env: docker compose up -d.
Upgrade, backups, and logs
docker compose pull
docker compose up -d
New SQL ships inside fizzlayer/fizzlayer-api. The migrate container applies files it has not applied before. An exited migrate container with code 0 is success.
docker compose exec -T db \
pg_dump -U postgres -d postgres --format=custom > fizzlayer.dump
docker compose logs --tail 100 api
docker compose logs --tail 100 worker
Troubleshooting
databaseisunavailable.- Wait until
docker compose logs migratesaysmigrations applied. Then checkrest,gateway, andauth. - The host is not configured.
- Set
FIZZ_PUBLIC_HOSTto the host and port in the browser, andFIZZ_PUBLIC_SCHEMEtohttporhttps. - Ingest returns 401.
- The header is
Authorization: Bearerplus the sensor key from creation. The key is shown once. - MQTT connection refused.
curl -fsS http://localhost:8081/healthshould include"mqtt_tcp":true, and port 1883 should be published.- Chat returns 503.
- No model is configured. Readings and the dashboard do not need one.
More cases, including a password mismatch after --force, are in the repository guide.