Building Marshall Labs: A Self-Hosted Application Platform

How I built a shared service platform so every project I build gets authentication, email, and user management from day one.

Every time I start a new web application, the first stretch of work is identical: user registration, email verification, password reset, login, logout, session management. Before writing a single line of domain-specific code I have already built an auth system from scratch — and usually cut corners doing it. After going through that cycle a few times, I decided to solve it once.

Marshall Labs is the result. It is a self-hosted application platform that provides identity, authentication, authorization, transactional email, and a shared user directory as reusable services. Any application registered with the platform inherits all of that on day one and can focus entirely on its own domain logic.

What is in the platform

The platform is composed of five components:

How a request moves through the stack

The api-proxy is the traffic cop. Every HTTP request from a client hits the proxy first. The proxy maintains a local cache of public routes — refreshed from idam-service every 60 seconds — so truly public endpoints skip the auth round-trip entirely. For everything else, the proxy performs two checks against idam-service: it validates the JWT (including a Redis blacklist lookup to catch logged-out tokens) and then confirms the requesting user holds a feature that grants access to the specific route and HTTP verb they are calling.

[Client] | | Authorization: Bearer <jwt> v [api-proxy] | |-- public route? ───────────────────────> [Destination Service] | └── protected route: 1. verify JWT ─────────────────────> [idam-service] 2. verify route ─────────────────────> [idam-service] 3. attach X-Idam-User-Auth header ────────────────────────────────────> [Destination Service]

Downstream identity without a second round-trip

Once the proxy validates a request it needs to tell the destination service who the caller is. The obvious approach — having every downstream service call idam-service on each request — would add latency and couple every service to IDAM at runtime.

Instead, after verification the proxy mints a signed X-Idam-User-Auth header and attaches it before forwarding. The header carries a small JSON payload (user ID, application ID, token ID, expiry) that is HMAC-SHA256 signed with a key shared between the proxy and idam-service. Downstream services verify the signature using labs.UserAuthGuard middleware from the SDK — that is their only IDAM dependency at runtime. No second network call, no added latency on the hot path.

The event-sourced user directory

idam-service is the system of record for user identity and application membership, but it is on the authorization hot path and is the wrong place for applications to ask "who are the members of my app, with their display names and photos?"

user-service solves this. It keeps an event-sourced replica of application membership by consuming lifecycle events from idam-service over RabbitMQ. When a user's membership is activated, idam publishes a member-activated event; user-service consumes it and updates its app_memberships table. Applications read membership and profiles from user-service in a single call and never talk to idam-service directly on this path. If an event is ever dropped, a backfill command on idam-service re-publishes the current membership set idempotently, so the replica is self-healing.

Running it all on a Raspberry Pi

The platform runs on a Raspberry Pi 5 with 8GB of RAM and an NVMe SSD via HAT. The Pi lives on an isolated VLAN — it can reach the internet but is not reachable from the rest of the home network. Postgres backups run on a cron schedule, with pg_dump output transferred outbound to a separate machine on a dedicated VLAN via SSH/rsync. That backup machine has no inbound access to the host; all transfers are initiated from the Pi.

All platform secrets — database passwords, JWT signing keys, API tokens — live in Bitwarden Secrets Manager. Nothing is committed to the repository. The deploy scripts fetch secrets at runtime via the Bitwarden CLI and write a local .env that is never tracked in git. Rotating a secret means editing it in the Bitwarden web UI and running the update script, which fetches the new values, regenerates the env file, and restarts the affected service.

CI and deployment are driven by GitHub Actions with a self-hosted runner. The runner lives on the same Tailscale network as the Pi, which means it can reach the deploy host over the Tailnet without any open inbound ports on the Pi. The pipeline is straightforward: a push to main triggers the workflow on the self-hosted runner, tests run, and on success the runner SSHes into the Pi over Tailscale and calls the update script. That script pulls the latest source, fetches updated secrets from Bitwarden, rebuilds the Docker image natively on ARM64, and restarts the container. No image registry, no cross-compilation — the Pi builds its own images, which keeps things simple and sidesteps ARM64 compatibility headaches entirely.

The full stack — platform services, application services, infrastructure (Postgres, Redis, RabbitMQ, MinIO), and an observability stack (Loki, Promtail, Grafana) — runs as a single Docker Compose stack behind Nginx Proxy Manager, which handles SSL termination and Let's Encrypt certificate renewal. Grafana is accessible only via Tailscale, so it never touches the public internet.

What this unlocks

Any new application registered with the platform gets the full auth system — registration, email verification, login with password or Google, logout with immediate token blacklisting, password reset, route-based authorization, and transactional email — without writing any of it. The application backend receives a signed identity context on every authenticated request and can query the user directory for membership and profile data in a single call. The platform handles the plumbing; the application handles the product.

I have built a couple of applications on top of Marshall Labs already. They will be covered in upcoming articles.

Written by Daniel Marshall

Published on June 4th 2026