Home server + Cloudflare
The receiver gets a public hostname (https://looku.example.com) through a Cloudflare Tunnel, with no open ports at
home. A Cloudflare rule lets through only POST /ingest and POST /orders. The dashboard listens only on the home
network. First time? 2. Your first installation on a home server goes through the same
steps with explanations.
Needs: Linux with Docker Compose v2 or newer, a domain on Cloudflare, the server’s LAN address (here
192.168.1.10). Commands run on the server in ~/looku/deploy.
Install
Section titled “Install”cd ~ && git clone https://github.com/niemenjoki/Looku.git looku && cd looku/deploycp .env.example .envIn .env:
LOOKU_TIMEZONE=Europe/HelsinkiLOOKU_DASHBOARD_BIND=192.168.1.10LOOKU_API_HOSTS=192.168.1.10:8788| Setting | Why |
|---|---|
LOOKU_TIMEZONE |
time zone of the nightly job and the monthly email (names) |
LOOKU_DASHBOARD_BIND |
the dashboard port is published only on this address; 127.0.0.1 = the server only |
LOOKU_API_HOSTS |
accepted Host headers, against DNS rebinding. Add other names after a comma (server.local:8788); keep the port in step with LOOKU_DASHBOARD_PORT |
After any later change to .env: docker compose up -d. All settings: Settings.
mkdir -p data && sudo chown 1000:1000 datadocker compose up -ddocker compose psBoth containers show Up, after a minute (healthy). Then add the site and the password:
docker compose run --rm ingest site:add myblog --domain example.com --timezone Europe/Helsinkidocker compose run --rm -it ingest admin:passwordsite:addprintsSigning key (shown once):and the key. Store it;site:rotate-key myblogreplaces it.--timezonedecides when a day changes in the site’s reports;site:timezonechanges it later (recorded days keep their dates).admin:passwordasks once, without echo, at least 10 characters.docker compose restart dashboardends existing logins.- Use
run --rm ingestfor one-off commands.run dashboardmay restart the receiver first, because the dashboard depends on it.
The dashboard is at http://192.168.1.10:8788.
Tunnel
Section titled “Tunnel”In the Cloudflare dashboard (paths checked in November 2026; search for the word in brackets if a menu has moved):
-
Networking → Tunnels (“Tunnels”) → Create a tunnel, name
looku. -
Choose Docker and copy the token after
--token(starts witheyJ). Do not run the shown command. -
On the server:
LOOKU_TUNNEL_TOKEN=<token>in.env(the last line), then:Terminal window docker compose --profile tunnel up -dThe tunnel shows as connected in Cloudflare within a minute (Cloudflare calls
cloudflareda “connector”). -
In the tunnel: Routes → Add route → Published application (“Published application”): subdomain
looku, domainexample.com, path empty, service URLhttp://ingest:8787.
Firewall rule
Section titled “Firewall rule”Your domain → Security → Security rules (“Security rules”) → Create rule → Custom rules. Name
Looku: only POST /ingest and /orders, Edit expression:
(http.host eq "looku.example.com" and not (http.request.method eq "POST" and http.request.uri.path in {"/ingest" "/orders"}))Action Block → Deploy.
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://looku.example.com/ingestcurl -s -o /dev/null -w "%{http_code}\n" https://looku.example.com/curl.exe -s -o NUL -w "%{http_code}\n" -X POST https://looku.example.com/ingestcurl.exe -s -o NUL -w "%{http_code}\n" https://looku.example.com/| First | Second | Meaning |
|---|---|---|
401 |
403 |
correct: the receiver refused an unsigned request, Cloudflare blocked the rest |
403 |
the rule blocks the receiver too; check the expression | |
502, 530 |
the tunnel does not reach the receiver; see Troubleshooting |
docker compose --profile tunnel ps shows looku-ingest-1, looku-dashboard-1 and looku-tunnel-1 as Up.
The site’s settings are LOOKU_ENDPOINT=https://looku.example.com, LOOKU_SITE_ID=myblog and the signing key:
Connect a Next.js site or Node.js, Express, Cloudflare or your own.
Backups: Backups and updates. Dashboard away from home: Tailscale.