Switching to the IRC engine
Every Lurker upgrade restarts the container, and the container holds your IRC connections — so every upgrade has meant a reconnect: re-register, re-identify, rejoin, and a Quit/Join for everyone in your channels. The engine ends that. It is a second container that holds the IRC sockets and nothing else, and the ordinary upgrade never recreates it.
This page is the one-time switch, start to finish, for an instance you already run. Self-Hosting Lurker is the reference for the same feature — every setting, and the details this page keeps short.
It is entirely optional. Nothing below is required to keep running Lurker exactly as you do now.
What it costs
Honest list, so none of it is a surprise later:
- One last reconnect when you first enable it, and another if you ever turn it off.
- A second container, sharing your host's memory. It holds no data — no database, no uploads, no volumes.
- identd moves. If you answer ident (
LURKER_IDENTD_ENABLEDorLURKER_OIDENTD_FILE), that job belongs to the engine now, because the engine is the process holding the socket the network asks about. This is the one step people get wrong; it has its own section below. - Engine upgrades still drop IRC. They are rare and called out in release notes.
Step 0 — upgrade normally first
Do this before anything else, and let it sit for a bit:
docker compose pull
docker compose up -dNothing about the engine turns on by upgrading. The presence of LURKER_ENGINE_URL is the entire switch, and you have not set it. Concretely, on this release without the engine:
- no schema change, and no new tables — nothing is written anywhere new
- identd behaves exactly as before
- the app dials IRC itself, exactly as before
So a normal upgrade is a normal upgrade. Confirm your instance is healthy on the new version, and only then continue. If you switch at the same moment you upgrade and something misbehaves, you will not know which change caused it.
Step 1 — get the compose file
docker-compose.engine.yml ships in the repository, not inside the image, so pulling a new image is not enough:
git pull
ls docker-compose.engine.ymlIf you did not clone the repo — the DigitalOcean one-shot deploy, for instance — download that one file next to your docker-compose.yml.
Step 2 — set the shared secret
The app authenticates to the engine with it, and it is the only thing that does:
echo "LURKER_ENGINE_SECRET=$(openssl rand -hex 32)" >> .envStep 3 — decide how you invoke Compose ⚠
This trips people up, and it is worth thirty seconds now rather than a confusing hour later.
Compose stops auto-loading docker-compose.override.yml the moment you pass -f. The override is merged only during Compose's default file resolution; once you name files explicitly, you get exactly the files you name.
So if you have a docker-compose.override.yml — where most people keep their reverse-proxy network, their 113:113 mapping, their secrets — this silently drops it:
docker compose -f docker-compose.yml -f docker-compose.engine.yml up -d # ⚠ override NOT loadedList it too, and put the whole thing in .env so your everyday commands go back to being short:
# .env — include docker-compose.override.yml ONLY if you have one
COMPOSE_FILE=docker-compose.yml:docker-compose.engine.yml:docker-compose.override.ymlFrom here on, plain docker compose pull / docker compose up -d do the right thing. Check it before you go further:
docker compose config --services # expect: lurker, lurker-engine
docker compose config | grep -A3 'ports:' # your own mappings should still be hereStep 4 — move identd, if you answer ident
Skip this entirely if you do not run identd — most people do not, and networks that do not ask will not notice.
If you do, the settings and the port move from lurker to lurker-engine. Where they live decides the work:
- In
.env: nothing to edit. The overlay forwardsLURKER_IDENTD_ENABLED,LURKER_IDENTD_PORT,LURKER_IDENTD_BINDandLURKER_OIDENTD_FILEto the engine for you. - In an
environment:block onlurkerin your override: move that block tolurker-engine. The overlay cannot see values set there.
The host :113 mapping is yours to move as well, along with the oidentd bind mount if you use file mode. In docker-compose.override.yml:
services:
lurker-engine:
ports:
- '113:113' # built-in identd
volumes:
- ./oidentd:/oidentd # oidentd file mode…and delete the same from lurker. LURKER_OUTGOING_ADDR is the exception and stays on lurker: the app reads it and tells the engine which address to bind, though the address has to exist on the engine's host.
Full detail in Self-Hosting.
Step 5 — bring it up
docker compose pull
docker compose up -dThis one drops IRC. It recreates lurker with LURKER_ENGINE_URL set — the last reconnect you should need. Your client reconnects on its own, as after any restart.
Step 6 — prove it works
Ask the engine what it is holding. It publishes no port, so ask from inside the network:
docker compose exec lurker \
node -e "require('http').get('http://lurker-engine:8016/healthz',r=>r.pipe(process.stdout))"{"ok":true,"held":2} — held is the number of IRC connections it is keeping for you. If it is 0 while you are connected, the app is dialling IRC itself and something above is wrong; see Troubleshooting.
Now the test that actually matters. Join a channel, say something, then recreate only the app:
docker compose up -d --force-recreate lurkerWatch your IRC client. Your nick should be unchanged, your channels still joined, and nobody in them should see you Quit and Join. Anything said while the app was restarting arrives when it comes back. That is the whole feature, and you can run this any time without waiting for a release.
Living with it
Upgrades are what they always were:
docker compose pull
docker compose up -dOnly lurker is replaced. pull fetches nothing new for the engine, and up -d leaves it running.
What still drops IRC:
- An engine upgrade. Its tag is
engine-1, which moves only when a release changes the engine — rare, and called out in that release's notes. - A release needing
engine-2. Called out too, and for that one stop the engine first, then upgrade both:bashAn old engine that refuses the new app would otherwise hold every session open while the app dials its own and collides with its ghosts. (Sessions no app claims for an hour are ended —docker compose stop lurker-engine docker compose pull && docker compose up -dLURKER_ENGINE_ORPHAN_MS— but that is a backstop, not a procedure.) - Rebooting the host, obviously.
Features that need a newer engine. The wire between app and engine is versioned separately from either one, and it only ever gains optional fields — so a newer app talks to an older engine happily, right up to a feature that needs a field the engine has never heard of. Today that is two things, and both need an engine from Lurker 2.3.0 or later, because the engine is what dials:
- CertFP (a TLS client certificate on a network). Connecting without the certificate would log the user in as nobody.
- A proxy on a network. Connecting without it would put the user's real address on the wire while their settings say otherwise.
An app that finds itself pointed at an older engine refuses to connect that network and says so, rather than connecting without it. Settings → About shows which engine you have. Pull the engine image (its tag doesn't move; its contents do) and restart it.
While the app is down, the engine keeps what arrives: 4 MiB per connection, 256 MiB in total, tunable with LURKER_ENGINE_BUFFER_BYTES and LURKER_ENGINE_BUFFER_TOTAL_BYTES. Past the cap the oldest lines go and the app notes where the hole is. Deploys take seconds; the buffer covers hours of ordinary traffic.
Turning it off again
Stop the engine first, or the network briefly sees two of you. If you added the COMPOSE_FILE line, take the overlay out of it before bringing the stack back up — otherwise the plain command loads it again and restarts the engine you just stopped:
docker compose stop lurker-engine
# .env: COMPOSE_FILE=docker-compose.yml:docker-compose.override.yml
docker compose up -d --remove-orphansThat is one more reconnect, and you are back exactly where you started. Move identd back if you moved it.
If something is wrong
Two failures look similar and mean opposite things. docker compose logs lurker tells you which:
- Refused — wrong
LURKER_ENGINE_SECRET, or an app that needs a newer engine. The app says so at startup and falls back to dialling IRC directly for that run. IRC works; ident is not answered until you fix it, because:113is the engine's now. - Unreachable — the engine is down, or
LURKER_ENGINE_URLhas a typo. This is not a fallback. The app stays in engine mode and its connections wait, retrying and logging that they are waiting, because the sockets may well still be alive in there.
Other things worth checking:
heldis 0. The app never reached the engine. Confirm both containers are up (docker compose ps) and thatLURKER_ENGINE_SECRETis identical for both — the overlay reads it from.envfor each, so a value set in anenvironment:block on only one of them is the usual cause.- Your reverse proxy or
:113stopped working after switching. Almost certainly the-ftrap in Step 3: checkdocker compose configand confirm your override is inCOMPOSE_FILE. lurker-enginerestarts in a loop. Read its log (docker compose logs lurker-engine). It refuses to start withoutLURKER_ENGINE_SECRET, and with buffer values below 65536 bytes.
Running without Docker
The same two processes on one host, kept up by systemd. This assumes the layout most bare-metal installs already have — a checkout at /opt/lurker owned by a lurker user, started with npm start from a unit — and walks the same switch in the same order. Nothing here needs Docker.
npm start is tsx server/server.ts; npm run engine is tsx server/engine.ts, the other entrypoint in the same checkout: same node_modules, no build step, no database, nothing under data/. The two share nothing but LURKER_ENGINE_SECRET, and on one host they can share it the easy way: both read .env from their working directory, so one file serves both.
Step 1 — the engine unit
# /etc/systemd/system/lurker-engine.service
[Unit]
Description=Lurker IRC engine
After=network.target
[Service]
Type=simple
User=lurker
Group=lurker
WorkingDirectory=/opt/lurker
# The engine's `npm start`.
ExecStart=/usr/bin/npm run engine
Restart=on-failure
RestartSec=10
Environment=NODE_ENV=production
# Only if you answer ident: the engine binds :113 now, not the app.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.targetStep 2 — the app unit
Two changes to the unit you already have: the engine starts first, and the capability moves.
# /etc/systemd/system/lurker.service
[Unit]
Description=Lurker IRC
# Wants, not Requires: start the engine if it is down, but never stop or
# restart it because the app did — that is the whole feature.
Wants=lurker-engine.service
After=network.target lurker-engine.service
[Service]
Type=simple
User=lurker
Group=lurker
WorkingDirectory=/opt/lurker
ExecStart=/usr/bin/npm start
Restart=on-failure
RestartSec=10
Environment=NODE_ENV=production
# CAP_NET_BIND_SERVICE was here for :113. It belongs to the engine now; keep it
# on the app only if the app itself binds a port below 1024 (PORT=80).
[Install]
WantedBy=multi-user.targetStep 3 — .env
cd /opt/lurker
echo "LURKER_ENGINE_SECRET=$(openssl rand -hex 32)" >> .env
echo "LURKER_ENGINE_URL=tcp://127.0.0.1:8016" >> .envBoth units run from /opt/lurker, so both read this file; the engine ignores the URL and the app ignores the engine-only settings. Three things worth knowing:
- Leave
LURKER_ENGINE_LISTENunset. The engine binds127.0.0.1:8016by default, which is what you want here. The0.0.0.0:8016in the compose overlay is for containers, which cannot reach each other's loopback — on a host it would put the engine on every interface with the secret as its only lock. ⚠ Use127.0.0.1in the URL rather thanlocalhost, which can resolve to::1first and be refused. - identd needs no moving.
LURKER_IDENTD_ENABLED/LURKER_OIDENTD_FILEin.envare read by both processes: the app ignores them while an engine is configured, and the engine acts on them. What moves is the capability, in Step 2. (AnEnvironment=line in a unit wins over.env— the file never overrides a variable that is already set — so ident settings that live in the unit rather than the file have to be copied to the engine unit.) - A different host means widening the bind, and then a private network or a tunnel — see Self-Hosting.
Step 4 — bring it up
sudo systemctl daemon-reload
sudo systemctl stop lurker
sudo systemctl enable --now lurker-engine
sudo systemctl start lurkerThe stop and start are the one reconnect. The order matters if you answer ident: the engine binds :113 once, at boot, and does not try again, so the app has to have let go of it first — an engine started beside a running app finds the port taken, and once the app restarts nobody is answering. From here on, systemctl restart lurker leaves the engine alone.
Step 5 — prove it
curl -s http://127.0.0.1:8016/healthz # {"ok":true,"held":2}
journalctl -u lurker -n 50 --no-pager # "[lurker] engine mode: attached to 127.0.0.1:8016 (…)"
journalctl -u lurker-engine -n 50 --no-pager # "[identd] listening on :113", if you answer ident[engine] ident: built-in identd on :113 says only which mode the engine resolved; [identd] listening on :113 is the bind succeeding. [identd] failed to listen on :113: … EACCES means the capability is still on the wrong unit, and EADDRINUSE that the app was still holding the port when the engine started — the Step 4 order. Either way the engine carries on without ident rather than dying, and putting it right means restarting the engine, which drops IRC. Then the test that matters: sudo systemctl restart lurker while watching your IRC client — nick unchanged, channels intact, no Quit/Join.
Living with it
An upgrade is what it was, plus nothing:
cd /opt/lurker && git pull
npm run install:all && npm run client:build
sudo systemctl restart lurkerThe engine keeps running the code it started with — files changing on disk underneath it are the bare-metal equivalent of the engine-1 tag standing still. Restart it only when a release's notes say the engine changed, and for one that needs engine-2, stop it first:
sudo systemctl stop lurker-engine
cd /opt/lurker && git pull && npm run install:all && npm run client:build
sudo systemctl start lurker-engine && sudo systemctl restart lurkerTurning it off: first put the app unit back the way it was — drop the Wants= and After=lurker-engine.service lines, or the next restart of the app starts the engine straight back up, disabled or not, and restore the capability if you answer ident. Then:
sudo systemctl daemon-reload
sudo systemctl disable --now lurker-engine
sudo sed -i '/^LURKER_ENGINE_URL=/d' /opt/lurker/.env
sudo systemctl restart lurkerThe failures in If something is wrong read the same here, from journalctl -u lurker instead of docker compose logs lurker — with one twist for Refused. The app then answers ident itself, and without the capability it gave up in Step 2 that shows up in the app's log as [identd] failed to listen on :113: … EACCES. That is the refusal talking, not the capability: fix LURKER_ENGINE_SECRET and restart the app.
This path is exercised end to end by tools/manual-install-qa/run.sh in the repository: a throwaway systemd container and a local IRC server, the install above, and the units, .env lines, bring-up and turn-off run straight out of this page, with a client sitting in the channel to confirm that a restart of lurker drops nothing. So it is a supported way to run Lurker, not merely a possible one.