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.
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 ProxmoxProxmoxProxmox Virtual EnvironmentVirtualizationAn open-source Debian-based enterprise virtualization platform tightly coupling KVM hypervisors, LXC containers, ZFS storage, and clustered management. 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 AWSAWSAmazon Web ServicesCloudAmazon global cloud computing platform offering on-demand compute, object storage, content delivery, and managed infrastructure services across dozens of geographic regions. 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 DSQLDSQLAmazon Aurora DSQLCloudA serverless, distributed SQL database from AWS offering active-active multi-Region availability, PostgreSQL compatibility, and zero idle compute costs. relies on short-lived IAMIAMIdentity & Access ManagementSecurityA security framework of role-based and attribute-based policies controlling which identities (users, services, or roles) can perform actions on cloud resources. 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 NVMeNVMeNon-Volatile Memory ExpressStorageAn open logical-device interface specification engineered specifically for solid-state storage over PCIe buses, maximizing parallel I/O queues. ZFSZFSZettabyte File SystemStorageAn enterprise combined file system and logical volume manager featuring copy-on-write, data integrity checksumming, snapshots, and software RAID. 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 WANWANWide Area NetworkNetworkingA telecommunications network that extends across large geographic distances—connecting branch offices, data centers, and cloud regions via leased fiber, MPLS, or SD-WAN. ports.
By splitting the pipeline across the S3S3Simple Storage ServiceCloudAmazon Web Services scalable object storage offering 99.999999999% (11 9s) of data durability with fine-grained IAM policies and versioning. boundary—just like inbound email—each side only does what it is naturally good at:
- AWS Edge Ingestion (
CloudFront→Lambda→S3): A tiny, stateless Node.js Lambda function receives public/api/sendbeacons, extracts the client IP from CloudFrontCloudFrontAmazon CloudFrontCloudAWS global content delivery network (CDN) that securely delivers data, static assets, and APIs at low latency with edge caching and Origin Access Control., and writes a tiny JSONJSONJavaScript Object NotationArchitectureA lightweight, language-independent text data-interchange format (RFC 8259) representing structured key-value objects and arrays across web APIs and configuration files. 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. - Homelab Core (
systemdPuller →Umami+PostgreSQLon ZFS): Inside the private LANLANLocal Area NetworkNetworkingA computer network interconnecting hosts, servers, and switches within a limited geographic footprint such as a residence, server room, or office building., a scheduled sync script pulls queued JSON events from S3 over outbound HTTPSHTTPSHypertext Transfer Protocol SecureSecurityAn extension of HTTP that encrypts and authenticates client-server communication over Transport Layer Security (TLS) to prevent eavesdropping and tampering. (TCP 443), replays them into the local Umami container with original client IP andUser-Agentheaders intact, and deletes the processed objects from S3.
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" };
}
};
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 LXCLXCLinux ContainersVirtualizationOS-level virtualization technology providing lightweight, near-bare-metal isolated Linux user spaces using kernel cgroups and namespaces. 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 (TerraformTerraformHashiCorp TerraformDevOpsAn open-source Infrastructure as Code (IaC) tool that provisions, manages, and versions multi-cloud resources declaratively via HashiCorp Configuration Language.): Provision the
home-lab-net-analytics-logsS3 bucket (with a 7-day lifecycle rule), the Node.js ingestion Lambda, and the CloudFront/api/sendbehavior. - Phase 2 — Internal Umami + PostgreSQL LXC: Deploy Umami with
TRUST_PROXY=trueand PostgreSQL backed by the Proxmox host’s NVMe ZFS pool, exposed internally athttps://analytics.home-lab.netbehind Caddy and Authelia SSOSSOSingle Sign-OnSecurityAn authentication scheme (powered by SAML 2.0 or OIDC) that enables a user to log in once with a central Identity Provider and gain access to multiple independent software systems.. - Phase 3 — Systemd Pull Timer: Configure
umami-sync.serviceandumami-sync.timer(OnUnitActiveSec=5min) using the least-privilege IAM pull credentials. - Phase 4 — Cutover from Umami Cloud: Point
stuurman.com,home-lab.net, andalwaysroute.comat the self-hosted ingestion endpoint.

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.