Published5 min read

Redeeming the Humble Pie: A Planned Zero-Port Umami Analytics Pipeline with Lambda, S3 & Proxmox

Last year I failed to shoehorn Umami Analytics into AWS Aurora DSQL. Here is the simpler, near-$0 architecture conceptualized for HOME-LAB.net: serverless Lambda + S3 edge ingestion paired with an outbound pull into local PostgreSQL on ZFS.

Note

Blueprint Status (Planned Target Architecture): Like the Zero-Port Homelab Mail blueprint, this article documents a planned architecture conceptualized for HOME-LAB.net and stuurman.com. It is queued for implementation once the current UniFi core network and Proxmox VE lab server hardware upgrades are complete.

Back in November 2025, I wrote about eating a big serving of humble pie when I tried to self-host Umami Analytics on AWS for $0.00 a month.

My mistake back then was trying to force the entire Umami stack—both its Next.js frontend and its relational PostgreSQL database—into serverless cloud primitives (AWS Amplify + Aurora DSQL). Because DSQL relies on short-lived IAM authentication tokens rather than standard PostgreSQL connection strings, the whole experiment collapsed under its own complexity. I shelved it and promised to revisit it when a cleaner idea came along.

While conceptualizing the S3 store-and-forward mail pipeline for HOME-LAB.net, it finally clicked: I was trying to run the database in the wrong place.


1. The Insight: Separate Ingestion from Storage

Running PostgreSQL in the cloud 24/7 is expensive. Running PostgreSQL in a container on my local Proxmox VE server backed by NVMe ZFS mirrors costs nothing—the server is already running.

Conversely, exposing an internal homelab Umami container directly to the public internet so every visitor’s browser can POST /api/send into my living room violates my primary homelab rule: zero open inbound WAN ports.

By splitting the pipeline across the S3 boundary—just like inbound email—each side only does what it is naturally good at:

  1. AWS Edge Ingestion (CloudFront → Lambda → S3): A tiny, stateless Node.js Lambda function receives public /api/send beacons, extracts the client IP from CloudFront, and writes a tiny JSON event into an S3 buffer bucket. At personal-site traffic levels, this stays inside the AWS Lambda Free Tier and costs pennies per year in S3 requests.
  2. Homelab Core (systemd Puller → Umami + PostgreSQL on ZFS): Inside the private LAN, a scheduled sync script pulls queued JSON events from S3 over outbound HTTPS (TCP 443), replays them into the local Umami container with original client IP and User-Agent headers intact, and deletes the processed objects from S3.

2. Private Homelab Core (Zero Open WAN Ports)

1. Public Edge Ingestion (AWS CloudFront + Lambda + S3)

POST /api/send

Viewer IP + User-Agent

PutObject (.json)

Outbound HTTPS 443 Pull(List / Get / Delete)

Replay POST /api/send(X-Forwarded-For + User-Agent)

Public Browser

AWS CloudFront Edge

AWS Lambda (Node.js Ingestion)

Encrypted S3 Buffer Bucket(7-Day Lifecycle Safety Net)

Local Node.js Sync Worker(systemd 5-min timer)

Internal Umami Instance(TRUST_PROXY=true)

PostgreSQL on Host ZFS(NVMe Mirror Dataset)


2. Public Ingestion: Stateless AWS Lambda → S3

Instead of keeping a database awake in AWS, the public /api/send endpoint only needs to validate the JSON payload, read the real client IP from AWS’s request context, and drop a file into S3:

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({});

export const handler = async (event) => {
  try {
    // Extract viewer IP directly from CloudFront / API Gateway context
    const realClientIp =
      event.requestContext?.identity?.sourceIp ||
      event.headers?.["cloudfront-viewer-address"]?.split(":")[0];

    const payload = {
      body: JSON.parse(event.body),
      client_ip: realClientIp,
      user_agent: event.headers?.["user-agent"],
    };

    const filename = `umami-events/${Date.now()}-${Math.random().toString(36).substring(2, 7)}.json`;

    await s3.send(
      new PutObjectCommand({
        Bucket: "home-lab-net-analytics-logs",
        Key: filename,
        Body: JSON.stringify(payload),
        ContentType: "application/json",
      })
    );

    return {
      statusCode: 200,
      headers: {
        "Content-Type": "application/json",
        "Access-Control-Allow-Origin": "https://www.home-lab.net",
      },
      body: JSON.stringify({ success: true }),
    };
  } catch (err) {
    console.error(err);
    return { statusCode: 500, body: "Storage failed" };
  }
};
Tip

Reboot & Maintenance Immunity: Just like the S3 mail relay, if the Proxmox host is powered down for hardware maintenance or a kernel reboot, public pageviews are never dropped. Events queue quietly in S3 (backed by a 7-day auto-expiry lifecycle rule) until the homelab comes back online.


3. Outbound Pull & Local Replay (TRUST_PROXY=true)

Inside an LXC container in the homelab, a lightweight Node.js worker polls the S3 bucket every 5 minutes via a systemd timer.

Because Umami supports TRUST_PROXY=true, the local worker can replay each stored payload to http://10.0.0.x:3000/api/send while passing the original visitor’s User-Agent and X-Forwarded-For IP header—preserving country/region geolocation and device breakdowns without ever exposing Umami to the internet:

import { S3Client, ListObjectsV2Command, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: "ap-northeast-1" });
const BUCKET_NAME = "home-lab-net-analytics-logs";
const INTERNAL_UMAMI_URL = "http://10.0.0.50:3000/api/send";

async function syncEvents() {
  const listResponse = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET_NAME }));
  if (!listResponse.Contents?.length) return;

  for (const file of listResponse.Contents) {
    const fileData = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: file.Key }));
    const logEntry = JSON.parse(await fileData.Body.transformToString());

    const response = await fetch(INTERNAL_UMAMI_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "User-Agent": logEntry.user_agent,
        "X-Forwarded-For": logEntry.client_ip,
      },
      body: JSON.stringify(logEntry.body),
    });

    if (response.ok) {
      await s3.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: file.Key }));
    }
  }
}

syncEvents();

Ring-Fenced IAM Permissions

The homelab pull worker uses a tightly scoped IAM policy limited strictly to s3:ListBucket, s3:GetObject, and s3:DeleteObject on the analytics buffer bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "BucketListing",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::home-lab-net-analytics-logs"
    },
    {
      "Sid": "ObjectReadAndDestroy",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::home-lab-net-analytics-logs/*"
    }
  ]
}

4. Build To-Do Checklist

This pipeline is queued up alongside the mail stack once the UniFi core network and lab server upgrades are finished:

  • Phase 1 — Edge Ingestion (Terraform): Provision the home-lab-net-analytics-logs S3 bucket (with a 7-day lifecycle rule), the Node.js ingestion Lambda, and the CloudFront /api/send behavior.
  • Phase 2 — Internal Umami + PostgreSQL LXC: Deploy Umami with TRUST_PROXY=true and PostgreSQL backed by the Proxmox host’s NVMe ZFS pool, exposed internally at https://analytics.home-lab.net behind Caddy and Authelia SSO.
  • Phase 3 — Systemd Pull Timer: Configure umami-sync.service and umami-sync.timer (OnUnitActiveSec=5min) using the least-privilege IAM pull credentials.
  • Phase 4 — Cutover from Umami Cloud: Point stuurman.com, home-lab.net, and alwaysroute.com at the self-hosted ingestion endpoint.
Ed Stuurman

About the author

Ed Stuurman

Infrastructure and reliability engineer based in Japan. Passionate about resilient systems, bare-metal hardware, cloud automation with Terraform, and all things homelab.

Contact

Thoughts or questions on this article?

Found a bug, tested a similar setup, or want to discuss the details? Drop a message below.

Emails are delivered directly to ed@stuurman.com.