Self-Hosting Lurker
This guide walks through running your own Lurker server, from the first docker compose up -d through optional features like passkeys, push notifications, and exposing your instance to the internet over HTTPS.
If you just want the TL;DR, the Quickstart gets you a working instance on http://localhost:8015 in two commands.
Quickstart
You need Docker (with the Compose plugin). On a fresh machine:
curl -O https://raw.githubusercontent.com/amiantos/lurker/main/docker-compose.yml
docker compose up -dThat's it. Open http://localhost:8015 in your browser and follow the first-run wizard to create your admin account (username + password). You're now connected to a Lurker server that will stay running across reboots; pair it with one or more IRC networks from the in-app settings.
All persistent state lives in a ./data/ directory next to your docker-compose.yml — back that up to back up Lurker.
First-run wizard
The very first time you open the app it'll prompt you to create the initial admin user. You pick a username and a password (8+ characters). That user is automatically promoted to admin, which means they can:
- Invite additional users (each user gets their own IRC networks, history, and settings)
- Reset their own password from the settings panel
- Eventually manage the system from the admin panel
Lurker is multi-user — anyone you invite gets their own private set of networks. There is no public sign-up; new accounts can only be created through admin-issued invite links.
Updating
docker compose pull
docker compose up -dRun these from the directory holding your docker-compose.yml. If you used the one-shot DigitalOcean deploy, that's /opt/lurker — cd there first; the command is identical whether or not you enabled HTTPS.
Lurker auto-migrates its SQLite schema on boot, so updates are a pull + restart. The data/ directory is not touched.
If something goes wrong, your data/ directory still has your last-known-good state — back it up before major updates if you want a clean rollback path.
Backups
Everything Lurker persists lives in ./data/:
lurker.db(and-shm,-walfiles) — IRC history, settings, users, etc.session-secret.key— the secret used to sign session cookies. Backing this up means existing browser sessions survive a restore.preview-cache/— only if you enabled preview image caching, and worth excluding: it's up to 2 GiB of re-fetchable bytes that would otherwise land in every backup. Restoring without it costs a re-fetch and nothing else.
A cp -r data/ data-backup-$(date +%F)/ (with the server stopped, to avoid copying mid-write WAL files) is sufficient. If you need a hot copy, use the SQLite .backup command:
docker exec lurker sqlite3 /app/data/lurker.db ".backup '/app/data/lurker-snapshot.db'"Then copy data/lurker-snapshot.db out.
Exposing Lurker to the internet (recommended: Cloudflare Tunnel)
Lurker is a single-user-per-account always-on IRC client — most operators want to reach it from their phone or laptop while away from home. The simplest, most reliable way to do this is a Cloudflare Tunnel (cloudflared). You get:
- A public HTTPS URL on a domain you already own (terminated at Cloudflare's edge — no certificate management on your end)
- No port forwarding, no router configuration, no inbound firewall holes
- Works behind CGNAT, on a residential network, or anywhere with outbound HTTPS
- Free for personal use
Starting from a blank VPS? If you don't already have a host, the one-shot DigitalOcean deploy brings up a fresh droplet with Lurker and automatic HTTPS (via Caddy) from a single pasted script — no SSH, no manual Docker install. The rest of this section covers exposing an instance you're already running.
Setup
Own a domain on Cloudflare. You don't need to buy one through Cloudflare, but the DNS does need to be managed there. (Cloudflare's free plan is fine.)
Create the tunnel in the Cloudflare dashboard:
- Go to Zero Trust → Networks → Tunnels → Create a tunnel, pick "Cloudflared", name it
lurker, and copy the install command Cloudflare gives you. The command embeds a token tied to this tunnel.
- Go to Zero Trust → Networks → Tunnels → Create a tunnel, pick "Cloudflared", name it
Add
cloudflaredto yourdocker-compose.ymlalongside Lurker:yamlservices: lurker: # ... existing config ... cloudflared: image: cloudflare/cloudflared:latest container_name: lurker-tunnel restart: unless-stopped command: tunnel run environment: - TUNNEL_TOKEN=eyJ...your-token-here...Then
docker compose up -d. The tunnel container will phone home to Cloudflare and stay connected.Route a hostname to Lurker. Back in the Cloudflare dashboard, under your tunnel's "Public Hostname" tab, add:
- Subdomain:
lurker(or whatever you want) - Domain: pick one of your zones
- Service:
http://lurker:8015(the container talks to Lurker over Docker's internal network)
Cloudflare provisions DNS automatically. Within a minute,
https://lurker.example.comresolves and serves your Lurker instance over HTTPS.- Subdomain:
Update Lurker's environment so passkeys and push notifications know about the public hostname (see Optional features below). At minimum, if you plan to enable passkeys:
yamlenvironment: # ... existing config ... - WEBAUTHN_RP_ID=lurker.example.com - WEBAUTHN_RP_NAME=Lurker - WEBAUTHN_ORIGIN=https://lurker.example.comThen
docker compose up -dto apply.
Alternative: any reverse proxy
If you already run Caddy, Traefik, nginx, or another reverse proxy with an automatic-TLS story, point it at http://localhost:8015 (or attach Lurker to your proxy network). For passkeys / push, the public origin must match WEBAUTHN_ORIGIN.
Two things the proxy must get right, because Lurker's live connection is a WebSocket:
- Forward the WebSocket upgrade. The
/wsendpoint needs theUpgradeandConnectionheaders passed through. Caddy and Traefik do this automatically. For nginx you have to add it explicitly (see below). - Forward the browser's host — either preserve the original
Hostheader, or sendX-Forwarded-Host. Lurker's WebSocket does a same-origin check on the upgrade (a CSRF protection against cross-site socket hijacking), comparing the browser'sOriginagainst the host it sees. A proxy that rewritesHostto the upstream address (127.0.0.1:8015) breaks that match, and the socket is rejected with a403. Caddy and Traefik sendX-Forwarded-Hostby default; for nginx, setHost(shown below).
A minimal nginx location that satisfies both:
location / {
proxy_pass http://127.0.0.1:8015;
proxy_set_header Host $http_host; # same-origin WS check (see note)
proxy_set_header Upgrade $http_upgrade; # WebSocket upgrade
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}Use $http_host, not $host, for the Host line: $host drops the port, so on a non-standard port the forwarded host (irc.example.com) won't match the browser's Origin (irc.example.com:8443) and the check still fails. $http_host forwards the host and port verbatim, so it's correct on any port. (On the standard 443 the two are equivalent.)
If you'd rather not (or can't) fix the forwarded host, set CORS_ORIGIN to your public origin as an explicit allowlist instead — e.g. CORS_ORIGIN=https://irc.example.com. It accepts a comma-separated list, and a trailing slash is tolerated, but the scheme, host, and port must otherwise match the address you load Lurker at exactly.
Upgraded to 1.1.1 and the connection stopped working? This same-origin check is new in 1.1.1. If your reverse proxy rewrites
Hostand you don't setCORS_ORIGIN, the WebSocket now403s where it used to connect. Addproxy_set_header Host $http_host;(orX-Forwarded-Host), or setCORS_ORIGIN, per above.
Optional features
Passkeys (WebAuthn)
Lurker works fine with just username + password — passkeys are a quality-of-life addition (fingerprint / Face ID / hardware key login). (The one-shot DigitalOcean deploy sets these up for you.) To enable them elsewhere, set three environment variables that match the public origin your browsers actually hit:
environment:
- WEBAUTHN_RP_ID=lurker.example.com # hostname only, no scheme, no port
- WEBAUTHN_RP_NAME=Lurker
- WEBAUTHN_ORIGIN=https://lurker.example.com # full origin, scheme + portWEBAUTHN_ORIGIN can be comma-separated if you log in from multiple URLs (e.g. a dev hostname and your public Cloudflare URL).
Restart Lurker, log in with your password, then visit Settings → Passkeys and register one. Passkeys require HTTPS for any non-localhost hostname — browsers won't allow the WebAuthn ceremony otherwise.
Lost your passkey? Just log in with your password and remove the dead passkey from the settings panel.
Web Push notifications
Lurker supports background push notifications for highlights and DMs, delivered to your installed PWA even when the tab is closed. (The one-shot DigitalOcean deploy sets VAPID_SUBJECT for you.) To enable it elsewhere:
Set a valid
VAPID_SUBJECT(the contact address embedded in outgoing push JWTs — APNs requires a real domain):yamlenvironment: - VAPID_SUBJECT=mailto:you@example.comRestart Lurker. The first time the push service is used, it generates a VAPID keypair and stores it in
data/lurker.db(underapp_meta). The same keypair is reused on subsequent boots so existing subscriptions keep working.From a browser (HTTPS required), open Lurker, "Install" it as a PWA, and enable notifications in the settings.
If you change VAPID_SUBJECT later, existing subscriptions continue to work — the subject only affects new push JWTs, not the keypair.
Push and the mobile apps
Short version: install Lurker as a home-screen PWA and use Web Push above. That is the supported path for a self-hosted server, and it works on iOS 16.4+ and Android with no developer account and no extra configuration.
The first-party Lurker mobile apps use native push (APNs on iOS, FCM on Android), and a self-hosted server cannot deliver to them. This isn't a missing feature — it's how the platforms work:
- An APNs signing key only signs for the bundle id Apple issued it to.
- An FCM token is scoped to the Firebase project compiled into the APK; a token from a different project is rejected outright (
MismatchSenderId).
So only the publisher of a build can push to that build. Supplying your own Apple or Google credentials to LURKER_APNS_* / LURKER_FCM_SERVICE_ACCOUNT will not make your server able to push to the App Store or Play Store app — those variables exist for whoever publishes the app, and for anyone running their own build of it signed with their own credentials.
You can check what a given server can actually deliver on: GET /api/push/config returns a transports list. A self-hosted server reports ["webpush"], and the apps use that to tell you push isn't available rather than asking for notification permission and then silently never delivering.
File uploads on your own disk
By default, images you paste or drop into the message box are uploaded to a third-party host (x0.at). If you'd rather keep them on your own server, pick local in Settings → Uploads. Lurker then writes the file to disk and serves it back from your own instance, and the link it pastes into IRC points at you — no third party involved.
Files land in uploads/ next to the SQLite database (so they're on your mounted volume and already covered by the backup advice above). Point them somewhere else with:
environment:
- LOCAL_UPLOADS_DIR=/data/uploadsThe link Lurker pastes into IRC has to be an absolute URL, or nobody else can open it. Lurker works the origin out from the incoming request, which is right for most reverse-proxy setups. If your links come out with the wrong hostname or scheme, pin it explicitly:
environment:
- PUBLIC_BASE_URL=https://lurker.example.comCloudflare users: turn off Hotlink Protection
If you expose Lurker through Cloudflare (including a Cloudflare Tunnel), Hotlink Protection will break local uploads. It's a Cloudflare feature that blocks image files whenever they're loaded from a page on another domain — which is exactly what an uploaded image is once you share the link on IRC. Cloudflare returns a 403 at the edge and the request never reaches Lurker, so the image loads for you but is broken for everyone else.
Fix it in the Cloudflare dashboard under Scrape Shield → Hotlink Protection → Off. If you want to keep it on for the rest of your site, leave it enabled and add a Configuration Rule that turns it off just for your uploads:
- When incoming requests match:
URI Pathstarts with/uploads/ - Then the settings are:
Hotlink Protection→Off
If you set this rule up before Lurker 1.0 it will say /uploads/local/. Uploads now live directly under /uploads/, so widen it — the shorter prefix still matches the old links, which keep working.
See Uploaded images are broken for other people if you've already hit this.
Link previews & inline media
Off by default. When enabled, a link pasted into chat can unfurl into a preview card (title, description, image — the way Slack or Discord do it); a link straight to an image renders inline; and a link to a video or audio file renders a poster frame that plays in place when clicked.
⚠ That last one needs one more setting: video posters require the byte cache (LURKER_PREVIEW_CACHE_MODE=local). See Caching preview images below — without it, video links get a card with no frame on it.
It's off by default because it makes your deployment fetch third-party URLs that appear in chat — a behavior an operator should choose, not inherit.
You run a second container: lurker-previews
All of the fetching and media parsing happens in a separate service, lurker-previews, not in the main server. That split is the point: the main process holds your users' sessions and your database, and it never dials a stranger's URL or runs an image/video decoder (sharp, ffmpeg) on bytes someone pasted. The decoder does that, in a box built to be thrown away if it's ever compromised.
Setting LURKER_PREVIEWS_URL to point at a running decoder is the entire enable switch — there's no separate on/off flag.
If you run the stock docker-compose.yml, there's a ready-made overlay that adds the decoder and sets that variable for you:
curl -O https://raw.githubusercontent.com/amiantos/lurker/main/docker-compose.previews.yml
docker compose -f docker-compose.yml -f docker-compose.previews.yml up -d(Add -f docker-compose.caddy.yml too if you front Lurker with Caddy.) On a DigitalOcean droplet, ENABLE_LINK_PREVIEWS="true" in the deploy script does all of this — including the hardening below — unattended.
If you keep your own compose file, the service is this:
services:
lurker:
environment:
- LURKER_PREVIEWS_URL=http://lurker-previews:8030
# ...your other settings
lurker-previews:
image: ghcr.io/amiantos/lurker-previews:latest
restart: unless-stopped
# The decoder is meant to reach the public internet but nothing on your
# private network. It proves that at boot and REFUSES TO START if it can
# reach a private address — which, on a plain Docker network, it can (the
# host is one hop away). For a home/VPS self-host, tell it to skip that
# check: the in-process SSRF guard still refuses private URLs, so this is the
# same protection the feature had before it was split out. See "Hardening"
# below to enforce it at the network layer instead.
environment:
- LURKER_PREVIEWS_ALLOW_PRIVATE=1
# Optional: what the decoder tells sites it is when it fetches a preview.
# The default names Lurker and the traffic class (a social-preview fetch),
# so an operator reading their logs can recognise and block it on purpose.
# - LURKER_PREVIEW_USER_AGENT=
# No ports published — only the Lurker container talks to it, over the
# shared Docker network.⚠ Both containers must be on the same Docker network. Declaring lurker-previews as a service in the same compose file does this for you — that is the whole reason the block above is one file. If you instead start the decoder on its own (a separate docker run, or a different compose project), the Lurker container can't resolve the name lurker-previews, every preview silently fails to resolve, and nothing renders even though the settings are on. The cell logs one throttled link-preview decoder unreachable line when this happens — check for it if previews stay blank.
Enabling the instance doesn't show anyone a preview yet. The feature is double-gated: each user also has two toggles in Settings → Chat — Link previews and Inline media — both defaulting to off. Those toggles only appear once a decoder is configured, so if a user can't find the setting, LURKER_PREVIEWS_URL is the reason.
The decoder identifies itself honestly in its User-Agent, guards every fetch against SSRF (loopback, RFC-1918, link-local including cloud metadata, CGNAT and IPv4-in-IPv6 all refused, with DNS pinned so the answer can't change between the check and the connection), and proxies preview images back through your Lurker server so users' browsers never touch the third-party host. A video or audio clip is never relayed — only its poster frame is — so pressing play goes straight to the origin, the one request the reader deliberately made.
Hardening: enforce the egress limit at the network layer
LURKER_PREVIEWS_ALLOW_PRIVATE=1 turns off the decoder's boot self-test so it runs on an ordinary Docker network. The in-process SSRF guard is still active, so a malicious URL is refused either way — what you give up is protection against a malicious process (an RCE in the decoder reaching your LAN). For a trusted group that's a fair trade; to close it, firewall the decoder so it can reach the internet but not private ranges, and let the self-test run.
On a Linux host, deploy/previews-egress.sh does that for you:
# with LURKER_PREVIEWS_ALLOW_PRIVATE=0 in a .env beside your compose file
curl -O https://raw.githubusercontent.com/amiantos/lurker/main/deploy/previews-egress.sh
chmod +x previews-egress.sh
sudo ./previews-egress.sh --installTwo things to set alongside it:
- Stop skipping the self-test. The overlay reads
LURKER_PREVIEWS_ALLOW_PRIVATEfrom your.env, so=0there is enough. If you wrote your own compose file from the block above, that hardcoded- LURKER_PREVIEWS_ALLOW_PRIVATE=1wins over any.env— delete the line. - Give the self-test a target that answers. Its built-in probes are the cloud metadata address and the bridge gateway's
:22/:80; on a box with no sshd and nothing on:80they all time out whether or not your rules exist, and it passes vacuously. Name something private you know is listening — usually your Docker host's LAN address — viaLURKER_PREVIEWS_SELFTEST_TARGETS=192.168.1.10:22.
The script adds DOCKER-USER rules dropping traffic from the decoder's address to 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 and 100.64.0.0/10, plus an INPUT rule for the host itself (⚠ container→host traffic never reaches DOCKER-USER, so forward rules alone leave your own sshd reachable from the container that parses hostile input). Replies to Lurker are allowed by connection state rather than by subnet, so the decoder can answer Lurker but cannot dial it. --install adds a systemd unit that re-applies everything on boot and whenever Docker restarts. Then it restarts the decoder and reads back its verdict, so you know it worked.
⚠ DNS is deliberately allowed — port 53 to any address. Without it, a host whose resolver is a LAN address (a home router, a Pi-hole, systemd-resolved pointing at either) resolves nothing, every preview fails, and the self-test still passes, because it probes IP literals and cannot see DNS at all. What it grants a compromised decoder is talking to DNS servers; not ssh, not an internal API, not the metadata service. To close even that, give the decoder public resolvers of its own with dns: in the overlay and delete the two port-53 rules.
Re-run the script after two things:
- Anything that recreates the decoder container. The rules are scoped to the address Docker gave it, and an update can hand it a new one. This failure is loud: the self-test stops passing, the decoder refuses to serve, and previews report themselves unavailable while everything else carries on.
- Any change to a host firewall that owns the filter table (
ufw allow …,firewall-cmd). A reload rewritesINPUTand takes the host-protection rule with it. ⚠ This one is not loud — the decoder only re-tests its containment when it starts — so it is the one case where you have to remember. The script warns at the end when it sees ufw or firewalld running.
(This is what the hosted fleet does per-cell, and why.)
Caching preview images (required for video posters)
For links and images this is a tuning knob: without a cache everything still works, the server just re-fetches an image when nobody's browser has it cached. For video and audio it is the difference between a poster frame and a bare card, so if you want the feature described at the top of this section, turn it on.
Why a video needs it when an image doesn't: a poster is the one preview image with no origin URL. The decoder produces those bytes from the video itself, so they exist nowhere else on the internet — if your instance has nowhere to put them, there is nothing to serve later. The server is consistent about that rather than half-working: with the cache off it doesn't ask the decoder for a poster at all, won't record one against the link, and its poster route answers 404. The card renders without a frame and nothing logs, because a posterless card is a supported state.
⚠ Turning it on doesn't backfill. A video someone pasted while the cache was off is remembered as posterless for a week (the success TTL); it grows a frame once that entry expires and someone posts the link again.
LURKER_PREVIEW_CACHE_MODE trades a little disk or a bucket for this, and for not re-fetching popular images:
off(default) — fetch through, store nothing. No video posters.local— the sensible self-host choice. Cached bytes live in a directory next to the database (2 GiB cap by default, least-recently-used eviction). It's a cache, not data: safe to delete while the server is stopped.s3— for instances already running behind a CDN: cached images go to a bucket you own and get served from its public base URL, so your server ships zero bytes for an image it has already fetched. This one has real operational requirements — the objects are publicly readable, the base URL must be https, and you own eviction via a lifecycle rule on the bucket.
Posters work under local or s3 — the gate is "a cache exists", not local specifically.
Misconfiguration is never fatal: a bad cache config logs one warning, caching turns off, and previews keep working.
Every variable is carried as a commented block in docker-compose.previews.yml, on the lurker service — storing bytes needs credentials, and the container that parses hostile input is the last place to keep any, so the decoder hands bytes back and Lurker decides where they go. The full per-mode reference — every field, the s3 caveats, and the reasoning — lives in .env.example, which is worth reading in full before turning on s3.
Secure cookies
Lurker's session cookies are not flagged Secure by default. This sounds wrong but is correct for the common self-hosted shapes:
- LAN / Tailscale /
*.localhostnames over plain HTTP — browsers drop Secure cookies on non-localhost HTTP origins - Cloudflare Tunnel, reverse proxies, etc. — the browser sees HTTPS, but the container sees plain HTTP from the proxy, so even with TLS in front the cookie travels cleartext over Docker's internal network (which is fine — that traffic never leaves the host)
If you genuinely serve Lurker over end-to-end HTTPS (Express terminating TLS directly), set:
environment:
- COOKIE_SECURE=trueAuth rate limiting behind a proxy
Lurker throttles repeated failed logins per client IP (a per-IP backoff on the login, token, and password-change endpoints, plus a coarse request cap on the auth surface). By default it uses the connection's socket address, which is correct when Lurker faces the internet directly.
If you run Lurker behind a reverse proxy or tunnel (Caddy, nginx, Cloudflare), the socket address is the proxy, so every visitor would share one bucket. Set this only when a proxy you control populates X-Forwarded-For, so Lurker keys on the real client:
environment:
- LURKER_TRUST_PROXY=trueDo not set it on a directly-exposed instance — X-Forwarded-For is then attacker-spoofable and an attacker can dodge the limit by rotating a fake value.
Custom session secret
By default Lurker generates a random 64-byte secret on first boot and writes it to data/session-secret.key (mode 0600). All session cookies are signed with it. If you'd rather supply your own (e.g. pulled from a secrets manager), set:
environment:
- SESSION_SECRET=replace-me-with-a-long-random-stringWhen set, the env var takes precedence and the file is ignored.
Outbound contact info (User-Agent)
When Lurker talks to external services (image hosts, link previews, etc.) and replies to CTCP VERSION on IRC, it identifies itself with a User-Agent string. Set USER_AGENT_CONTACT to a mailto: or URL so the operators of those services can reach you if your instance misbehaves:
environment:
- USER_AGENT_CONTACT=https://lurker.example.comUnset, it falls back to the upstream project link.
IRC bouncer (attach from other IRC clients)
Lurker can act as a bouncer (ZNC- and soju-compatible): enable the built-in IRC listener and any ordinary IRC client (WeeChat, irssi, Textual, HexChat, …) can attach to the same always-on connection your web UI uses — same nick, same channels, recent history replayed on attach, and anything you send from the client lands in your Lurker history and web tabs too. Detaching never disconnects you from IRC.
environment:
- LURKER_BOUNCER_ENABLED=true
- LURKER_BOUNCER_PORT=6667 # remember to publish this port in docker-composePoint your IRC client at the host/port with a server password of:
username:secret— when you have one network configuredusername/networkname:secret— to pick one of several (the network's name as shown in the web UI, or its numeric id)
The secret can be your Lurker account password, but a read-write API token (web UI → Settings → API tokens) is the better choice — IRC clients store the server password in plaintext config files, and a token can be revoked without changing your password.
Modern IRCv3 clients (Halloy, gamja, Goguma, …) get more than the server-password floor above:
- SASL — log in with the same credential via SASL PLAIN instead of a server password.
- Network discovery (
soju.im/bouncer-networks) — the client lists and binds your networks itself, so you don't hardcodeusername/networkname; connect as justusernameand pick from the list. - On-demand scrollback (
draft/chathistory) — page back through history on demand instead of relying only on the fixed replay-on-attach.
These are negotiated automatically; plain clients that don't support them keep working over the server-password path.
TLS
Plain-text IRC would send that credential across the wire in the clear, so the bouncer speaks TLS by default — you don't have to do anything to get an encrypted connection. Connect in your IRC client's TLS/SSL mode. There are three ways the cert is sourced:
Self-signed (default, zero setup). With no cert configured, Lurker generates a self-signed cert on first boot and persists it next to the database (so it survives container rebuilds). It's the ZNC model: the wire is encrypted, and to also protect against man-in-the-middle you pin the certificate's fingerprint in your client. Lurker prints the SHA-256 fingerprint at startup — in the container logs and in the in-app system buffer — e.g.
TLS certificate fingerprint (SHA-256): AB:CD:…. Most clients (WeeChat, irssi, Textual, …) let you pin that fingerprint; do it once and any impostor cert is rejected thereafter.Your own Let's Encrypt cert (browser-trusted, no pinning). If you want a cert clients trust without pinning, get one for a hostname (e.g.
irc.example.com) with certbot and point Lurker at the PEM files — bind-mount them into the container and set:yamlenvironment: - LURKER_BOUNCER_TLS_CERT=/certs/fullchain.pem - LURKER_BOUNCER_TLS_KEY=/certs/privkey.pemNote the bouncer is raw IRC over TCP, so your HTTP reverse proxy (Caddy/Cloudflare) can't front it — the bouncer terminates its own TLS. Lurker re-reads the cert files periodically and hot-swaps a renewed cert, so certbot renewals need no restart.
Plain-text (opt-in, private networks only). If — and only if — you keep the listener private (
LURKER_BOUNCER_BIND=127.0.0.1behind an SSH tunnel, or a VPN/Tailscale interface), you can turn TLS off withLURKER_BOUNCER_TLS=off. On a non-loopback bind without TLS, Lurker logs a loud security warning. Don't do this on a public address.
Repeated failed logins from an address are throttled automatically.
Playback replays the last 50 lines per joined channel (plus your 20 most recently active DMs) on attach; tune with LURKER_BOUNCER_PLAYBACK (0 disables, max 1000). Clients that negotiate IRCv3 server-time get real timestamps on replayed lines.
Known limitations (shared-connection bouncer semantics): replies to one attached client's WHOIS/LIST are visible to all attached clients on that network; Lurker-side ignore rules don't filter the live relay; and on end-to-end encrypted channels an attached client sees the wire ciphertext for incoming messages.
Troubleshooting
Forgot the admin password
The cleanest path is to invite a second admin from your phone if you're still logged in there, then have them reset things from the admin panel.
If you're locked out everywhere, the fallback is to clear the password hash directly with sqlite and re-bootstrap. With the server stopped:
docker compose down
sqlite3 data/lurker.db "DELETE FROM users WHERE username = 'your-username';"
docker compose up -dThis destroys that user's account and history. If you were the only user, the next visit will return you to the first-run wizard so you can create a fresh admin. (A proper password-reset CLI is on the roadmap.)
Port 8015 already in use
Edit the ports: line in your docker-compose.yml — the first number is the host port:
ports:
- '9999:8015'Now Lurker is reachable on http://localhost:9999.
Reverse-proxy / CORS errors
If you're seeing browser console errors about CORS, your browser is hitting a different origin than what Lurker expects. The bundled image serves both the API and the UI from the same port, so the default no-CORS_ORIGIN config is correct for almost everyone. Only set CORS_ORIGIN if you're running the Vue dev server (npm run dev) against a containerized API, or doing something similarly unusual.
A related failure mode is a WebSocket that 403s while the page itself loads fine — the UI appears but never connects, and this typically shows up right after upgrading to 1.1.1. That's the same-origin check on the /ws upgrade, not a browser CORS error. It means your reverse proxy isn't forwarding the browser's host to Lurker. Fix it at the proxy (proxy_set_header Host $http_host; on nginx, or X-Forwarded-Host), or set CORS_ORIGIN to your public origin. See Alternative: any reverse proxy for the full location block. When you do set CORS_ORIGIN, it must match the address you load Lurker at exactly on scheme, host, and port — a trailing slash is fine and a comma-separated list is allowed, but http vs https or a stray port will not match.
Uploaded images are broken for other people (403)
Symptom: you're using the local uploader, and an uploaded image loads fine when you open the link in a new tab, but shows as broken when it's embedded — in someone else's client, or in Lurker's own image viewer on a different domain.
That asymmetry is the tell. Opening a link directly and embedding it on a page are different requests: the embedded one carries a Referer header naming the page it's embedded on. Cloudflare's Hotlink Protection blocks image files whose Referer is a different domain, returning a 403 at the edge before the request ever reaches Lurker.
Confirm it with two curls against the same URL, where the only difference is the Referer header:
# 1. No referer → 200, and Lurker serves the image
curl -sS -o /dev/null -D - \
https://lurker.example.com/uploads/<key>.<ext> \
| grep -iE '^HTTP/|^content-type:'
# HTTP/2 200
# content-type: image/webp ← or image/jpeg, depending on the upload
# 2. Cross-domain referer → 403, and your image never gets served
curl -sS -o /dev/null -D - -H 'Referer: https://example.org/' \
https://lurker.example.com/uploads/<key>.<ext> \
| grep -iE '^HTTP/|^content-type:|^vary:'
# HTTP/2 403
# content-type: text/plain; charset=UTF-8
# vary: refererIf adding a Referer is all it takes to flip a 200 into a 403, that's Hotlink Protection. The 403 comes back as text/plain (Cloudflare's block page) rather than your image, and vary: referer is Cloudflare telling you the decision was made on the referer.
Don't try to tell the two apart by looking for
server: cloudflare— Cloudflare proxies the successful response too, so that header is on both. The status flip is the signal.
Turn Hotlink Protection off (or scope it around /uploads/) — see File uploads on your own disk.
Container logs
docker compose logs -f lurkerWill stream Lurker's stdout, including connection events, push delivery results, and any tracebacks.
Advanced: docker-compose.override.yml
Compose auto-merges a docker-compose.override.yml file (gitignored, never committed) on top of the main docker-compose.yml. This is the clean way to add your own settings without touching the upstream file — useful if you want to git pull updates without conflicts.
A starter template is checked in as docker-compose.override.yml.example. Copy it to docker-compose.override.yml and edit. The example shows the pattern the upstream maintainer uses (pulling secrets from a .env file, attaching to an external reverse-proxy network).
Running without Docker
If you'd rather run Lurker directly on a host:
git clone https://github.com/amiantos/lurker.git
cd lurker
npm run install:all
npm run client:build
npm startThe server listens on port 8010 by default. Configure with the same envvars described above (set them in a .env file next to package.json, or export them in your shell). Use a process supervisor (systemd, pm2, etc.) to keep it running: it restarts the server after a crash, which nothing else on this page does. Backgrounding with disown and logging out also works — the server survives its terminal going away — but console output is gone for good at that point (the in-app system log keeps recording), and a crash stays down until you notice.