Back to Blog · Software Architecture

Self-Hosted Server Analytics with GoAccess and Laravel: No JavaScript, No SaaS

How to pipe nginx access logs through GoAccess on Fly.io, embed the HTML report inside a Filament admin panel, and keep your visitor data off third-party servers.

MF
Martin Fournier
· June 13, 2026 · 5 MIN READ
Illustration for: Self-Hosted Server Analytics with GoAccess and Laravel: No JavaScript, No SaaS

Most personal sites run Google Analytics, Plausible, or Fathom. Each requires a JavaScript snippet, a third-party DNS lookup, and a data-processing agreement. For a Laravel site deployed on Fly.io behind nginx, there is a simpler path: parse the access logs that nginx already writes, pipe them through GoAccess, and embed the resulting HTML report inside the Filament admin panel.

This post covers the full pipeline: dual nginx log formats that separate machine-readable logs from human-readable stdout, the custom log format that preserves real client IPs behind Fly.io's proxy, a Laravel service class that orchestrates GoAccess subprocesses, and a Filament page that renders the report in an iframe. No JavaScript. No SaaS. No cookies.

The nginx Log Architecture

The site runs nginx inside the Fly.io Docker image. Nginx writes access logs to two destinations:

access_log /var/www/html/storage/logs/nginx-access.log goaccess_combined;
access_log /dev/stdout fly;

The first destination writes to a persistent volume so reports survive deploys. The second writes to stdout for Fly.io's log drains (fly logs). Two different log formats serve two different readers.

The fly format is a standard Combined-shaped log that uses $remote_addr as the first field. Fly.io routes replace $remote_addr with the internal gateway IP, so every request appears to come from the same address. That format is useless for analytics. It exists only for Fly.io's log aggregation.

The goaccess_combined format solves the IP problem with an nginx map directive:

map $http_fly_client_ip $forwarded_header_value {
    default $http_fly_client_ip;
    "" $http_x_forwarded_for;
}

log_format goaccess_combined  '$forwarded_header_value - $remote_user [$time_local] "$request" '
                              '$status $body_bytes_sent "$http_referer" '
                              '"$http_user_agent"';

The map checks Fly.io's custom Fly-Client-Ip header. If present, it uses that value. If absent (local dev, direct connections), it falls back to X-Forwarded-For. The result: every log entry carries the real visitor IP, and GoAccess can produce accurate geography and unique-visitor statistics.

The Laravel Service Layer

The GoAccessReportGenerator service encapsulates everything: log path resolution, existence checks, file size reporting, GoAccess binary detection, and report generation. The class is a thin wrapper around Symfony's Process component.

public function generate(): void
{
    if (!$this->logIsReady()) {
        throw new RuntimeException(
            'Nginx access log is empty or missing.'
        );
    }

    $process = new Process(['bash', $script, $this->logPath(), $this->reportPath()]);
    $process->setTimeout(120);
    $process->run();
}

The actual GoAccess invocation lives in a shell script:

goaccess "$LOG_FILE" \
    --log-format=COMBINED \
    --ignore-crawlers \
    --anonymize-ip \
    --no-color \
    -o "$REPORT_FILE"

The --ignore-crawlers flag filters out bots and search engine crawlers. The --anonymize-ip flag truncates the last octet of every IP before writing the report. Both are essential for privacy: the raw log contains full IPs, but the report never exposes them.

The Filament Admin Integration

The SiteAnalytics Filament page renders an iframe pointing to an authenticated route at /admin/site-analytics/report. The report file lives under storage/app/goaccess/, not public/. Only logged-in Filament admins can access it.

A header action button called Refresh report triggers GoAccessReportGenerator::generate() and notifies the admin on success or failure. The page header shows the current log file size and the timestamp of the last successful report generation.

A dashboard widget displays the same metadata in a compact card: log size, report age, and two links (one to open the full analytics page, one to trigger a refresh).

The Artisan command analytics:goaccess wraps the same logic for SSH-based invocation via fly ssh console. This is the fallback when the admin panel is unreachable or the log is too large for subprocess generation.

Why This Works

Three design decisions make this pipeline practical:

  1. Dual log formats decouple concerns. The storage log is optimized for GoAccess. The stdout log is optimized for Fly.io. Changing one never breaks the other.

  2. The report is a static HTML file. GoAccess generates a self-contained HTML report with inline CSS and JavaScript. No database, no cron job, no queue worker. The report is a file on disk that gets regenerated on demand.

  3. The security boundary is clear. Raw logs stay on the persistent volume. Reports go to storage/app/ with no public URL. The Filament route enforces authentication. There is no way to access analytics without admin credentials.

Limitations

This setup is not for high-traffic sites. GoAccess parses the entire log file each time it generates a report. On a personal site with a few thousand requests per month, regeneration takes under a second. At millions of requests, the subprocess timeout (120 seconds) would be tight, and you would want incremental parsing.

Similarly, the log file grows unbounded. A manual rotation script keeps a backup and truncates the file. For production systems, you would add logrotate. For a personal site, checking file size every few months is sufficient.

The Result

Six months ago, this site had zero analytics. Adding Google Analytics felt wrong for a personal site that values privacy. Adding Plausible meant another subscription and another DNS lookup on every page load. This GoAccess pipeline required no new infrastructure, no JavaScript, and no monthly bill. It is a Laravel service class, a shell script, and an nginx config block. That is the right size for the problem.