goaccess-live-nginx-dashboard¶
Modules
A NixOS module that serves GoAccess's real-time HTML log dashboard over nginx, updated live in the browser over a WebSocket, and gated to an IP allow-list. It builds GoAccess with MaxMind GeoIP support explicitly enabled and optionally keeps the GeoLite2 databases fresh.
What it solves¶
GoAccess can tail an nginx access log and render a live HTML dashboard (top URLs, visitors, geo map, status codes, …) that updates in place via a WebSocket. Wiring that up cleanly on NixOS means gluing together a custom package build, a systemd service, an nginx vhost with a WebSocket proxy, and GeoIP data. This module packages all of it behind a handful of options.
The dashboard exposes full visitor logs, so it is never public: both the
static page (/) and the WebSocket (/ws) are locked to an allowedIPs
allow-list, with deny all behind it.
The two traps this exists to fix¶
1. GoAccess must be built with MaxMind MMDB support¶
Without --enable-geoip=mmdb and libmaxminddb, GoAccess simply ignores the
.mmdb files and the geo map comes up empty. Rather than fork the package, the
module overrideAttrs pkgs.goaccess to append --enable-geoip=mmdb and
--enable-utf8 and add libmaxminddb to buildInputs.
Note the history here, because the rationale has shifted: this override was
originally load-bearing, because nixpkgs' goaccess was built without MMDB
support. Current nixpkgs enables it by default (the package takes
withGeolocation ? true and already passes both flags), so on an up-to-date
nixpkgs the override is redundant rather than required. It is kept because it is
harmless — duplicate configure flags are fine — and it keeps the module working
on older pins and on a goaccess overridden with withGeolocation = false.
2. The log-format % signs must be doubled (%%h)¶
GoAccess's --log-format/--date-format/--time-format strings are full of
% directives (%h, %d, %t, …). Those strings are interpolated into a
systemd ExecStart=, and in a systemd unit a bare % is a specifier
(%h = the user's home directory, etc.). systemd would silently rewrite your
format before GoAccess ever saw it.
The fix is to double every percent: %%h, %%d, %%t. %% is systemd's
literal-percent escape; it collapses back to a single % before the argument
reaches GoAccess. This is not a Nix quirk — % is not special in Nix
strings — it is purely a systemd-unit escaping rule. The default logFormat
in this module is already correctly doubled; keep any custom format doubled
too. (Named presets like COMBINED contain no % and need no escaping.)
Usage¶
{
imports = [ ./goaccess-live-nginx-dashboard ];
services.goaccessDashboard = {
enable = true;
domain = "stats.example.com";
# Who may view the dashboard. It leaks full request logs — keep this tight.
allowedIPs = [ "203.0.113.0/24" "198.51.100.7" ];
# Reuse an existing ACME cert instead of requesting a fresh one:
# useACMEHost = "example.com";
};
}
Requires services.nginx (the module enables it) and a working ACME/TLS setup
for domain — either point useACMEHost at an existing certificate or let the
module request its own (configure security.acme.acceptTerms / defaults.email
yourself).
Key options¶
| Option | Default | Purpose |
|---|---|---|
enable |
false |
Turn the module on. |
domain |
— (required) | FQDN the dashboard is served on. |
allowedIPs |
loopback + RFC1918 | IPs/CIDRs allowed to view / and /ws. Set this. |
useACMEHost |
null |
Reuse a named ACME cert; null = request own cert. |
accessLog |
/var/log/nginx/access.log |
Log GoAccess tails. |
logFormat |
combined + vhost | --log-format, percent signs doubled. |
dataDir |
/var/lib/goaccess |
On-disk DB + rendered HTML. |
realTimePort |
7890 |
Loopback port for the WebSocket feed. |
htmlTitle |
Web Server Analytics |
Title rendered at the top of the dashboard. |
openFirewall |
false |
Open realTimePort; unnecessary in the normal nginx-proxied setup. |
geoipDatabaseDir |
/var/lib/geoip-databases |
Where the .mmdb files live. |
geoipDatabases |
GeoLite2-City.mmdb, GeoLite2-ASN.mmdb |
Filenames passed as --geoip-database; the first doubles as the preStart presence probe. |
geoipUpdater.enable |
false |
Opt-in timer that downloads GeoLite2 DBs from a third-party mirror. |
geoipUpdater.interval |
weekly |
OnCalendar refresh cadence for that timer. |
geoipUpdater.databases |
City + ASN from the public mirror | { name; url; } pairs the updater fetches. |
dateFormat, timeFormat, user/group, uid/gid round out the set; all
follow the same conventions (percent signs doubled for the format strings).
GeoIP databases¶
The geo map needs GeoLite2 .mmdb files. Two ways to supply them:
- Provision them yourself (default / recommended). With
geoipUpdater.enable = false(the default) you populategeoipDatabaseDiranother way — e.g. nixpkgs'services.geoipupdatewith a free MaxMind license key. The GoAccess servicepreStarthard-fails if the first configured database is missing, so you get a clear error instead of an empty map. - Bundled updater (opt-in).
geoipUpdater.enable = trueinstalls a systemd timer that downloads the databases intogeoipDatabaseDir. It pulls from a public GitHub mirror that republishes MaxMind's GeoLite2 files without an account or license key. Trade-off: you trust that mirror's freshness and integrity (no checksum verification) — see Security notes. PointgeoipUpdater.databasesat MaxMind's own authenticated URLs if you have a license key.
The updater's oneshot uses RemainAfterExit, and the GoAccess service
Wants/After it, so GoAccess won't start until the databases exist at least
once.
Notes & caveats¶
--persist/--restore. GoAccess's DB is in-memory; these flags checkpoint it todataDir/dbso history survives restarts. Without them a restart drops all history back to the log file's current window.nginxgroup membership. The GoAccess user joins thenginxgroup (to read the access log) and thenginxuser joins the dashboard's group (to serve the generated HTML). Both directions are needed.- WebSocket URL. The service advertises
wss://<domain>/ws, matching the nginx/wsproxy location with a longproxy_read_timeoutso the live socket stays open. - Firewall.
realTimePortis bound to127.0.0.1and reverse-proxied by nginx, so it does not need to be open.openFirewallexists for unusual topologies but defaults off.
Security notes¶
- The bundled updater trusts a third-party mirror — that's why it's off by
default. With
geoipUpdater.enable = true, a weekly timer fetches.mmdbbinaries over HTTPS from a public GitHub account that republishes MaxMind's GeoLite2 files. There is no checksum/signature verification, so enabling it means trusting that account indefinitely: if it is compromised or hijacked, the replaced database is downloaded automatically on the next run and parsed bylibmaxminddbinside the GoAccess process. Prefer the default posture of provisioning the databases yourself (e.g. nixpkgs'services.geoipupdatewith a MaxMind license key), or pointgeoipUpdater.databasesat MaxMind's own authenticated URLs. - The rendered dashboard contains full visitor logs. The webroot
(
dataDir/html) is mode0750, owned by the GoAccess group that nginx joins, so local accounts outside that group cannot read it off disk. The IP allow-list (allowedIPs) is the only thing keeping it off the network — keep it tight.
Source¶
modules/goaccess-live-nginx-dashboard/default.nix
# GoAccess real-time HTML log dashboard, served over nginx + WebSocket,
# gated to an IP allow-list.
#
# A self-contained NixOS module. Import it and set at minimum:
#
# services.goaccessDashboard = {
# enable = true;
# domain = "stats.example.com";
# allowedIPs = [ "203.0.113.0/24" ]; # who may view the dashboard
# };
#
# See README.md for the two traps this module exists to solve:
# 1. nixpkgs' goaccess ships WITHOUT MaxMind MMDB support -> overrideAttrs.
# 2. the log-format '%' signs must be doubled ('%%h') because the string
# lands in a systemd ExecStart, where a bare '%' is a unit specifier.
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.goaccessDashboard;
# Trap 1: nixpkgs' goaccess is built without MaxMind (.mmdb) GeoIP support.
# Rebuild it with the geoip flag rather than forking the package.
goaccessWithGeoIP = pkgs.goaccess.overrideAttrs (oldAttrs: {
configureFlags = (oldAttrs.configureFlags or [ ]) ++ [
"--enable-geoip=mmdb"
"--enable-utf8"
];
buildInputs = (oldAttrs.buildInputs or [ ]) ++ [ pkgs.libmaxminddb ];
});
# Opt-in bundled GeoIP updater (geoipUpdater.enable = false by default).
# Fetches GeoLite2 .mmdb files from a public third-party mirror, with no
# integrity check -- see the option description and README before enabling.
# The recommended path is provisioning geoipDatabaseDir yourself (e.g. via
# nixpkgs' `services.geoipupdate`).
geoipUpdater = pkgs.writeShellScriptBin "goaccess-geoip-updater" ''
set -eu
GEOIP_DIR="${cfg.geoipDatabaseDir}"
mkdir -p "$GEOIP_DIR"
${concatMapStringsSep "\n" (db: ''
echo "Downloading ${db.name}..."
${pkgs.curl}/bin/curl -fL -o "$GEOIP_DIR/${db.name}" "${db.url}"
'') cfg.geoipUpdater.databases}
chmod 644 "$GEOIP_DIR"/*.mmdb
echo "GeoIP databases updated."
'';
geoipDbFlags = concatMapStringsSep " " (
db: "--geoip-database=${cfg.geoipDatabaseDir}/${db}"
) cfg.geoipDatabases;
in
{
options.services.goaccessDashboard = {
enable = mkEnableOption "GoAccess real-time nginx log dashboard";
domain = mkOption {
type = types.str;
example = "stats.example.com";
description = "Virtual host / FQDN the dashboard is served on.";
};
useACMEHost = mkOption {
type = types.nullOr types.str;
default = null;
example = "example.com";
description = ''
Name of an ACME certificate (`security.acme.certs.<name>`) to reuse for
TLS, or `null` to let this module request its own certificate for
`domain` (in which case configure `security.acme` yourself).
'';
};
accessLog = mkOption {
type = types.path;
default = "/var/log/nginx/access.log";
description = "nginx access log GoAccess tails.";
};
dataDir = mkOption {
type = types.str;
default = "/var/lib/goaccess";
description = "Where GoAccess keeps its on-disk DB and rendered HTML.";
};
user = mkOption {
type = types.str;
default = "goaccess";
description = "System user the GoAccess process runs as.";
};
group = mkOption {
type = types.str;
default = "goaccess";
description = "Primary group for the GoAccess user.";
};
uid = mkOption {
type = types.nullOr types.int;
default = null;
description = "Optional fixed UID; `null` lets NixOS allocate one.";
};
gid = mkOption {
type = types.nullOr types.int;
default = null;
description = "Optional fixed GID; `null` lets NixOS allocate one.";
};
allowedIPs = mkOption {
type = types.listOf types.str;
# The dashboard exposes full visitor logs, so it MUST be gated. There is
# no safe universal default -- set this to the operator/VPN/office ranges
# that should see it. The default below is loopback + RFC1918 private
# ranges as a fail-safe starting point; replace it.
default = [
"127.0.0.1"
"::1"
"10.0.0.0/8"
"172.16.0.0/12"
"192.168.0.0/16"
];
example = [ "203.0.113.0/24" ];
description = ''
IPs / CIDR ranges allowed to reach both `/` and `/ws`. Everything else
is denied. The dashboard leaks full request logs, so keep this tight.
'';
};
htmlTitle = mkOption {
type = types.str;
default = "Web Server Analytics";
description = "Title rendered at the top of the dashboard.";
};
logFormat = mkOption {
type = types.str;
# Trap 2: every '%' is DOUBLED because this string is interpolated into a
# systemd ExecStart, where a bare '%' is a unit specifier (%h = home dir,
# etc.) and '%%' is systemd's literal-percent escape. This is NOT a Nix
# thing -- '%' is not special in Nix strings.
#
# This default matches nginx's `combined` log format plus a trailing
# vhost field (`$host` / '%v'). Adjust it to whatever your nginx
# log_format actually emits, keeping every '%' doubled.
default = ''%%h %%^[%%d:%%t %%^] "%%r" %%s %%b "%%R" "%%u" "%%v"'';
description = ''
GoAccess `--log-format` string. Every `%` MUST be doubled (`%%h`)
because it is substituted into a systemd unit ExecStart. Named presets
like `COMBINED` also work (no percent signs, nothing to escape).
'';
};
dateFormat = mkOption {
type = types.str;
default = "%%d/%%b/%%Y";
description = "GoAccess `--date-format` (percent signs doubled -- see logFormat).";
};
timeFormat = mkOption {
type = types.str;
default = "%%H:%%M:%%S";
description = "GoAccess `--time-format` (percent signs doubled -- see logFormat).";
};
realTimePort = mkOption {
type = types.port;
default = 7890;
description = ''
Loopback TCP port GoAccess serves the WebSocket feed on. nginx reverse
proxies `/ws` to it; it is never exposed directly.
'';
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = ''
Open `realTimePort` in the host firewall. Usually unnecessary: nginx
proxies to it over loopback, so leave this off.
'';
};
geoipDatabaseDir = mkOption {
type = types.str;
default = "/var/lib/geoip-databases";
description = "Directory holding the GeoLite2 `.mmdb` files.";
};
geoipDatabases = mkOption {
type = types.listOf types.str;
default = [
"GeoLite2-City.mmdb"
"GeoLite2-ASN.mmdb"
];
description = ''
`.mmdb` filenames (inside `geoipDatabaseDir`) passed to GoAccess as
`--geoip-database`. GoAccess needs at least the City DB for the map.
'';
};
geoipUpdater = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Install a systemd timer that downloads the GeoLite2 databases into
`geoipDatabaseDir`. Off by default because the bundled default
`databases` URLs point at a third-party GitHub mirror and nothing
verifies the downloads' integrity -- enabling this is an explicit
decision to trust that mirror. Preferred alternative: provision the
`.mmdb` files yourself, e.g. with nixpkgs' `services.geoipupdate`
and a free MaxMind license key. Note the GoAccess service refuses to
start until the first database exists in `geoipDatabaseDir`.
'';
};
interval = mkOption {
type = types.str;
default = "weekly";
description = "systemd OnCalendar refresh interval for the databases.";
};
databases = mkOption {
type = types.listOf (types.submodule {
options = {
name = mkOption {
type = types.str;
description = "Output filename written into geoipDatabaseDir.";
};
url = mkOption {
type = types.str;
description = "URL to download the .mmdb from.";
};
};
});
# Public mirror that republishes MaxMind's GeoLite2 files without an
# account/license key. Trade-off: you trust the mirror's freshness and
# integrity (no checksum verification here). Swap for MaxMind's own
# authenticated downloads if you have a license key.
default = [
{
name = "GeoLite2-City.mmdb";
url = "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb";
}
{
name = "GeoLite2-ASN.mmdb";
url = "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-ASN.mmdb";
}
];
description = "GeoLite2 databases to fetch (name + download URL).";
};
};
};
config = mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
# Needs to read nginx's access log and write HTML into a webroot nginx serves.
extraGroups = [ "nginx" ];
description = "GoAccess web log analyzer";
} // optionalAttrs (cfg.uid != null) { uid = cfg.uid; };
users.groups.${cfg.group} = optionalAttrs (cfg.gid != null) { gid = cfg.gid; };
# nginx must be able to read the rendered HTML in the group-owned webroot.
users.users.nginx.extraGroups = [ cfg.group ];
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} - -"
"d ${cfg.dataDir}/db 0750 ${cfg.user} ${cfg.group} - -"
# 0750, not 0755: the rendered index.html embeds full visitor logs (IPs,
# URLs, referrers, user-agents). nginx serves it via its membership in
# cfg.group, so no world bit is needed -- keep local accounts out.
"d ${cfg.dataDir}/html 0750 ${cfg.user} ${cfg.group} - -"
]
++ optional cfg.geoipUpdater.enable "d ${cfg.geoipDatabaseDir} 0755 geoip geoip - -";
systemd.services.goaccess = {
description = "GoAccess real-time web log analyzer";
after = [ "network.target" "nginx.service" ]
++ optional cfg.geoipUpdater.enable "goaccess-geoip-updater.service";
wants = optional cfg.geoipUpdater.enable "goaccess-geoip-updater.service";
requires = [ "nginx.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
# GoAccess parses timestamps against the C locale unless told otherwise.
Environment = [
"LANG=en_US.UTF-8"
"LC_ALL=en_US.UTF-8"
];
# NOTE: the '%%' in logFormat/dateFormat/timeFormat are systemd escapes
# (see the option descriptions). systemd collapses each '%%' to a single
# '%' before goaccess ever sees the argument.
ExecStart = ''
${goaccessWithGeoIP}/bin/goaccess \
${cfg.accessLog} \
--log-format='${cfg.logFormat}' \
--date-format='${cfg.dateFormat}' \
--time-format='${cfg.timeFormat}' \
--real-time-html \
--html-report-title="${cfg.htmlTitle}" \
--ws-url=wss://${cfg.domain}/ws \
--port=${toString cfg.realTimePort} \
--addr=127.0.0.1 \
${geoipDbFlags} \
--db-path=${cfg.dataDir}/db \
--persist \
--restore \
-o ${cfg.dataDir}/html/index.html
'';
Restart = "always";
RestartSec = "10s";
PrivateTmp = true;
ProtectHome = true;
NoNewPrivileges = true;
ReadWritePaths = [
"${cfg.dataDir}/db"
"${cfg.dataDir}/html"
];
ReadOnlyPaths = [
(dirOf cfg.accessLog)
cfg.geoipDatabaseDir
];
};
# Fail loudly rather than render a dashboard with an empty world map.
preStart = ''
if [ ! -f ${cfg.geoipDatabaseDir}/${head cfg.geoipDatabases} ]; then
echo "GeoIP databases not found in ${cfg.geoipDatabaseDir}."
echo "Enable services.goaccessDashboard.geoipUpdater or provision them yourself."
exit 1
fi
'';
};
services.nginx.enable = true;
services.nginx.virtualHosts.${cfg.domain} = {
forceSSL = true;
useACMEHost = cfg.useACMEHost;
enableACME = cfg.useACMEHost == null;
root = "${cfg.dataDir}/html";
# Static dashboard HTML. IP-gated: the page contains full visitor logs.
locations."/" = {
index = "index.html";
extraConfig = ''
${concatMapStrings (ip: "allow ${ip};\n") cfg.allowedIPs}
deny all;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
'';
};
# WebSocket feed that pushes live updates into the open dashboard.
# Same allow-list, and the long read timeout keeps the socket alive.
locations."/ws" = {
proxyPass = "http://127.0.0.1:${toString cfg.realTimePort}";
proxyWebsockets = true;
extraConfig = ''
${concatMapStrings (ip: "allow ${ip};\n") cfg.allowedIPs}
deny all;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
'';
};
};
networking.firewall.allowedTCPPorts = optional cfg.openFirewall cfg.realTimePort;
environment.systemPackages = [ goaccessWithGeoIP ];
# --- Bundled GeoIP updater (optional) --------------------------------------
users.users.geoip = mkIf cfg.geoipUpdater.enable {
isSystemUser = true;
group = "geoip";
description = "GeoIP database updater";
};
users.groups.geoip = mkIf cfg.geoipUpdater.enable { };
systemd.services.goaccess-geoip-updater = mkIf cfg.geoipUpdater.enable {
description = "Download GeoLite2 databases for GoAccess";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
# RemainAfterExit keeps the unit "active" after success so goaccess.service
# (which Wants/After it) won't start until the databases exist at least once.
RemainAfterExit = true;
ExecStart = "${geoipUpdater}/bin/goaccess-geoip-updater";
User = "geoip";
Group = "geoip";
PrivateTmp = true;
ProtectHome = true;
NoNewPrivileges = true;
ReadWritePaths = [ cfg.geoipDatabaseDir ];
};
};
systemd.timers.goaccess-geoip-updater = mkIf cfg.geoipUpdater.enable {
description = "Refresh GeoLite2 databases periodically";
wantedBy = [ "timers.target" ];
partOf = [ "goaccess-geoip-updater.service" ];
timerConfig = {
OnCalendar = cfg.geoipUpdater.interval;
OnBootSec = "5min";
Persistent = true;
# Jitter so a fleet doesn't hammer the mirror at the same minute.
RandomizedDelaySec = "1h";
};
};
};
}