Troubleshooting
Symptom first. Where a problem is a known bug rather than a misconfiguration, it says so.
QSOs don't appear on other screens
Live updates run over Server-Sent Events backed by PostgreSQL LISTEN/
NOTIFY. Three things break it:
A connection pooler in transaction mode. PgBouncer's default silently
discards LISTEN. Nothing errors; updates just never arrive. Use session mode
or connect directly.
nginx buffering the stream. The app sets X-Accel-Buffering: no and the
deployed config disables proxy buffering. If you replaced that config, updates
arrive in bursts or not at all.
The browser lost the stream. SSE reconnects automatically, but a reload confirms it. The QSO list is fetched fresh on load, so a reload also resyncs.
Check the server is publishing at all:
# sudo -u postgres psql -d ezfd -c "SELECT COUNT(*) FROM qsos WHERE event_id='<uuid>';"
If the count rises but screens don't update, it's the transport.
The pending counter won't clear
QSOs are queued in browser local storage and flush when the server accepts them. A stuck counter means submissions are failing.
Click the pending badge to retry and watch for an error. Common causes are an
expired TLS certificate, the service being down, or — on a special event with
HARD enforcement — a rejection.
Queued QSOs are not lost while this persists. They stay in local storage until accepted. Don't clear site data.
"Nobody has checked out 20m PH right now"
A special event with checkout enforcement. Either check the band and mode out from the Call Checkout panel, or ask the coordinator to switch enforcement to warn-only in the admin console.
Under warn-only the QSO logs anyway and this is only a banner.
"W0AAA already holds 20m PH until 02:26Z"
Someone else has that band and mode. This is the constraint working as intended — special event rules generally permit one signal per band per mode.
Options: pick a different band or mode, wait for the slot to end, or ask them to release it early.
"W0AAA is not yet approved to operate this event"
The event requires roster approval and this operator is pending. A coordinator approves them in the admin console: List / manage events → open the event → Manage operator roster → Toggle approval.
At the event list, type the join code to open it straight away rather than finding its row — the operator asking you can read it to you.
"Nothing is holding this server's clock" / "has not been set for N days"
Different from the banner below: nothing currently disagrees, but the server has no way to know its time is right. No network time, no GPS, and no hardware clock — so the time it is showing came from its last shutdown rather than from a source.
This shows before anyone has logged anything, which is the point. A clock can be badly wrong and nothing will contradict it until an operator connects, and by then contacts may already carry the wrong time.
Fix it the same way: bash ezfd-admin.sh → Server time / clock. If the
machine will be offline for a whole event, fit a hardware clock or a GPS
receiver — see
Offline field servers → The clock is the part that bites.
After setting the time by hand, write it to the RTC if you have one, or the
correction lives only in RAM and the next reboot restores the old value.
This never fires just because NTP is off. An offline field server has no NTP by definition, and a warning that fires on that would shout loudest at whoever fitted an RTC and did everything right. It reports how long since the clock was last set by anything — a GPS-fed server is fine, an RTC-held one is fine until it has gone a long time unset, and a server that cannot answer the question at all says nothing rather than guessing.
On a Docker install this warning does not appear. The app runs in a
container that cannot ask the host what disciplines its clock, so it gets no
answer and, by the rule above, says nothing. The banner below still works.
Check the host's clock yourself with sudo bash ezfd-admin.sh →
Server time / clock before the first contact, which matters most on an
offline field server.
"This server's clock is N ahead of / behind this device"
The server and the browser disagree about the time by more than a minute. QSOs are timestamped by the server, so contacts logged while this is showing carry that wrong time.
Usually the server is the wrong one — a field server with no real-time clock
and no NTP. Fix it from the admin console: bash ezfd-admin.sh → Server time
/ clock. See Administration.
It can also be the operator's device that's wrong, if their laptop has been offline a long time. If one operator sees the banner and the others don't, it's that device.
You usually don't have to work that out yourself. With three or more operators connected, the banner compares what every device reports and says so outright — "9 of 11 connected devices agree" — because a dozen phones, most of them synchronised by a carrier within the last day, are collectively a better authority than a field server asking itself. Below three devices it keeps the both-ways wording, since with one or two there genuinely isn't enough to convict either side.
Either way, QSOs already logged keep the timestamps they were given. Fixing the clock only corrects contacts from that point on.
A QSO was deleted by mistake
Deleting a contact no longer removes it. Open the QSO table on the logging page and expand Deleted contacts at the bottom: each entry shows who deleted it and when, with a Restore button that puts it back in the log.
Deleted contacts leave the live log, the ADIF and Cabrillo exports and the score immediately, so a deletion still does what it looks like — it just isn't final any more. The full-event JSON backup keeps them, so a backup taken after a deletion can still account for it.
The one action that is final is the admin console's Delete ALL QSOs, which really does remove every row for the event, deleted ones included — it says how many of each before asking you to confirm. Nothing restores those. See Administration.
One thing this cannot tell you: operator identity here is a callsign typed at
join time, not a verified login. deleted by W0AAA records who claimed to
be operating, which is useful when two operators are reconciling a log after
the event, but it is not evidence.
A section is rejected as invalid
The entry form warns when a received section isn't one of the 85 current ARRL/RAC sections. The QSO still logs and the exchange is stored exactly as typed, so nothing is lost — but the section won't count toward Worked All Sections or appear on the map.
DX is accepted — that's the correct exchange from a station outside the US
and Canada. It's logged and exported normally, but it isn't a section, so it
doesn't count toward your sections-worked total or appear on the map.
Otherwise this is usually a typo. The other cause is an abbreviation RAC has
retired:
Maritime (MAR) is now NB / NS / PE, plain Ontario (ON) is now ONE /
ONN / ONS / GH, GTA was renamed GH, and Yukon is part of Northern
Territories (NT) rather than a section of its own. Log the current
abbreviation.
The "Sections Needed" panel lists anything logged that isn't a recognised section, so a typo is easy to find and correct while it still matters.
Logs recorded before EzFD tracked the current list may contain those retired abbreviations. They export as recorded, but they no longer match a section, so a very old log can show a lower worked-section count than it did at the time.
Cabrillo claimed score looks wrong
It shouldn't any more. CLAIMED-SCORE used to be computed separately from the
rest of the app and was wrong three ways — it multiplied by sections worked,
ignored the power multiplier and dropped bonus points (#12, fixed). The
export now calls the same calculateScore() the dashboard uses, so the header
and the app cannot disagree, and scripts/test-cabrillo.cjs asserts that in
CI.
If the two ever do disagree, that's a bug worth reporting rather than correcting by hand — the discrepancy would mean one of them is wrong and it isn't obvious which.
Rig control
The header's Rig button still reads off. The bridge must run on the same machine as the
browser — it connects to localhost:4575. Check the bridge window for errors.
Frequency readings are wrong or offset. Restart the bridge. A misread byte on the polling connection permanently offsets every later reading by one field, and it doesn't self-correct.
No CW button. The radio didn't report CAT keying support. Some rigs need it enabled in a menu; many don't have it at all.
CW sends "VFO" as text. A VFO argument is reaching the keying command.
rigctld only accepts one when started with --vfo; without it the literal
word gets keyed, which sounds like "4FO" on the air.
Permission denied on the serial port. On Linux, add yourself to dialout
and log back in.
More detail in Rig control.
The map says "API key required", or is blank behind the sections
Both are symptoms of the map fetching its background from somebody else's server, and it no longer does.
The map used to draw CARTO's tiles, which were open to anyone and then stopped being: unauthenticated tiles came back with "API key required" rendered into the image, so the map still drew, still placed every section in the right place, and was still unreadable — nothing failed, no request errored and nothing on screen said what had happened. Moving to OpenStreetMap's tiles removed the key, but not the dependency: a field server with no internet got sections floating on an empty background, and every EzFD install in the world pointed at a volunteer service.
The map now ships with the application. The land underneath and the section boundaries on top are both files the app serves itself, so the map draws in full on a field server with no internet, and no install fetches anything from a third party. Update the application and there is nothing to configure.
On an older build the map is the only thing affected — the section grid, Sections Needed, scoring and every export read the same data and are unaffected.
The map has no roads, towns or terrain
By design. The background is coastlines and land, drawn from Natural Earth's public-domain outlines, and the sections are drawn on top of it. Street-level detail would mean fetching tiles from a map service, which is what this replaced — and it would compete with the thing the map is actually for, which is seeing at a glance which sections are worked.
Zooming in gives you more section labels, not more ground detail.
The map is very dark, or the labels look wrong
Dark is the default, and the map follows it: the land is drawn dark and the worked sections amber on top of it, so the map does not glare next to the rest of the interface at 2am.
Switch with ☰ → Light / dark if you would rather have the light map. The border colours flip with the theme so that worked and unworked sections stay distinguishable in both; nothing about the data changes either way.
On the field server there is no "install app", and Web Serial is missing
Both are the same cause, and neither costs you a contact.
A field server runs on plain HTTP — there is no public domain to certify on a field LAN, and a self-signed certificate would just teach everyone to click through browser warnings. Browsers treat a plain-HTTP page as a non-secure origin and withhold a handful of APIs on it. Two of them are visible:
- Install to home screen (PWA) and the offline page cache both need a service worker, which needs a secure origin. On a field server this costs little in practice: the service worker exists to keep pages loading when a device leaves the network, and on a field server the server is the network.
- Rig control over Web Serial is unavailable. Use the Python bridge, which is the default transport and works the same on any origin.
Nothing that touches a contact is affected. Logging, the offline queue, live updates between operators and the bridge all work normally over plain HTTP — measured against a running server, not assumed. See Offline field servers → What plain HTTP costs.
If you want the missing pieces back, put the server behind a name you control with a real certificate; there is no way to get a secure origin on a bare LAN address without one.
The menu opens behind the map
On the dashboard's Map view, the slide-out menu appeared under the map: the map stayed bright where the rest of the screen dimmed, and its zoom buttons and the map's own credit line drew on top of the menu entries. The menu was still there and still worked — it was just unreadable where the two overlapped.
The map draws itself in layers with their own depths, and those depths used to compete with the rest of the page instead of staying inside the map. Update the application and it is fixed; there is nothing to configure. On an older build, switch to another dashboard view before opening the menu.
Duplicate QSOs after importing
Imports are idempotent — matched on callsign, band, mode and a ±2 minute window — so a re-import should skip rather than insert. If you have genuine duplicates from before that behaviour existed, the admin console's Clear all dupes marks flagged ones as non-duplicate, but it won't remove extra rows.
Note the distinction: a flagged duplicate is the same station worked twice on the air. An actually duplicated row is the same QSO recorded twice.
Event creation fails
"Invalid admin key" — the server sets EZFD_ADMIN_KEY and the form value
doesn't match.
"Server encryption key not configured" — you supplied QRZ credentials but
EZFD_ENCRYPTION_KEY isn't set. Set it, or create the event without QRZ.
Warnings about call databases — the event was created; only the optional prefill degraded. Upstream was slow or unreachable.
Restore fails or loses data
All three known failure modes are fixed, but if you're running an older build:
- Restoring an event with no QSOs used to fail outright, because
json_aggover zero rows serialises as JSONnullandCOALESCEdoesn't catch a scalar. - A special event's roster used to be dropped entirely, taking the per-
operator locations the ADIF
MY_*fields depend on with it. - A failed restore reported success.
psqlfed a script on stdin exits0even when a statement failed, so a restore that died on a constraint violation printed "Restore complete" and then rendered the error text through the results table.
Update before relying on restore. scripts/test-restore.sh verifies all
three.
"It said Restore complete but the event isn't there"
That is the third one above. The backup file is unharmed and a restore always creates new events, so simply update and run it again — retrying is safe. A current build stops and shows the error instead:
[✗] Restore failed:
ERROR: new row for relation "qsos" violates check constraint "qsos_mode_check"
The same bug affected exports, which logged "Backup complete" over a file that was empty or truncated. If you hold a backup taken on an older build and have never restored from it, check it parses before you rely on it:
$ jq 'length' /backup/ezfd-2026-06-28.json
A current build deletes the partial file and reports the failure rather than leaving something that looks like a backup.
Service won't start
# journalctl -u ezfd -n 50
Database connection refused — check PostgreSQL is running and
DATABASE_URL in /opt/ezfd/.env is right.
Port in use — something else has the port; nginx proxies to localhost.
Build artefacts missing — re-run deploy.sh.
On a Docker install, start with docker compose ps -a and the logs of
whichever service is not running:
$ docker compose ps -a
$ docker compose logs init
$ docker compose logs app
init exited with a code other than 0 — the schema step failed and the
app was never started. Its log names the statement. A password error there
usually means POSTGRES_PASSWORD was changed in .env after the first start;
see Configuration → The Docker Compose .env.
docker compose refuses to start, naming a variable — .env is missing
or lacks one of the three required settings. Copy .env.example to .env
and fill them in.
The site returns 502 after an update — the app container was replaced but did not come up, or the schema step failed after compose had already stopped the old app. Updating from the admin console avoids the second case; see Administration → Updating the application.
Out of memory during deploy
next build needs more than 1 GB. deploy.sh adds swap for this, but if you
disabled it or are building by hand, add swap or build elsewhere and copy the
output.