DEV Community

Cover image for I Built a Self-Hosted Platform That Deploys Client Apps and Gates Them Behind a Paywall When They Don't Pay
Michael Odhiambo
Michael Odhiambo

Posted on

I Built a Self-Hosted Platform That Deploys Client Apps and Gates Them Behind a Paywall When They Don't Pay

I built a self-hosted deployment platform that automatically deploys Docker apps, provisions nginx and SSL, and places overdue client projects behind a payment paywall that disappears automatically after payment.

Important context: This tool is designed for projects where the client has explicitly agreed to service suspension for overdue invoices in their contract. It is not a hostage mechanism—it is an automated enforcement of agreed payment terms, with instant restoration upon payment. Transparency with clients upfront is non-negotiable.

The problem: I hate chasing clients for money

If you freelance or run a small agency, you know the drill. You build a client's website or API, deploy it, and then the invoice goes out. The client loves the product. They use it every day. But when the payment date comes around, they go quiet.

I am not good at arguing with people about money. I do not enjoy the awkward back-and-forth of "hey, just checking in on that invoice" followed by three days of silence. I wanted a system that handled this for me, automatically, without me having to be the bad guy.

The second problem: deploying apps is a fight

Every time I needed to deploy a new client project, I had to:

  1. Pull the Docker image manually
  2. Create a container with the right ports, env vars, and volumes
  3. Write an nginx config by hand
  4. Get the SSL certificate set up
  5. Test the config, reload nginx, and pray nothing broke
  6. Repeat the whole process for the next client

It was tedious, error-prone, and every project had its own quirks.

Manual deployment Gatekeeperd
Docker CLI commands Validated wizard flow
Hand-written nginx configs Auto-generated + nginx -t safety check
Manual certbot runs One API call with wildcard reuse
Chasing invoices via email Automatic 402 paywall
Manual unblocking after payment Paystack webhook auto-restores access

What I built: Gatekeeperd

Gatekeeperd is a Ktor application written in Kotlin that runs on your VPS and gives you a JWT-protected API for the entire client project lifecycle.

Architecture

┌─────────────────┐
│  React Dashboard │
└────────┬────────┘
         │ HTTPS + JWT
┌────────▼────────┐     ┌──────────┐
│  Gatekeeperd API │────▶│  Redis   │ (gate status cache, 60s TTL)
│  (Kotlin/Ktor)   │     └──────────┘
└───┬────┬────┬───┘     ┌──────────┐
    │    │    │         │ PostgreSQL│ (projects, payments, audit log)
    │    │    │         └──────────┘
    │    │    └────────▶ Certbot (Let's Encrypt)
    │    └─────────────▶ nginx (reverse proxy + auth_request gating)
    └──────────────────▶ Docker Engine (containers, networks, images)
                         └────────▶ Paystack (payments + webhooks)
Enter fullscreen mode Exit fullscreen mode

See it in action

Active state: Admin dashboard shows Aaron's Car Wash project with green Active status badge. Right panel displays the live Swagger UI for acw.mikesplore.me loading normally with all endpoints accessible.
① Client site is live and fully accessible. Traffic flows through nginx to the container.

Blocked state: Admin dashboard now shows orange Manual Block status and KES 1,000 amount due. Right panel displays the Gatekeeperd paywall page with
② One click in the admin dashboard blocks the project. nginx immediately serves the 402 paywall instead of proxying to the container.

Payment flow: Paystack checkout modal open with Airtel Money selected, test mode indicator visible, KES 1,000 amount, and
③ Client clicks Pay Now → Paystack checkout opens. On success, the webhook fires, Redis cache clears, and the site auto-unblocks. No emails sent. No manual intervention.

The deployment engine

Instead of three separate manual workflows, Gatekeeperd unifies Docker, nginx, and SSL into a single validated pipeline. Each step is a discrete API call, orchestrated through the dashboard's wizard flow.

Step 1: Validate before you mutate

Before creating anything, the wizard calls POST /api/docker/validate to catch problems early:

POST /api/docker/validate
Content-Type: application/json

{
  "image": "ghcr.io/mikesplore/carwash-api:1.0.0",
  "hostPort": 9921,
  "containerPort": 8080,
  "network": "gatekeeper-internal"
}
Enter fullscreen mode Exit fullscreen mode
{
  "imageExists": true,
  "portAvailable": true,
  "networkAvailable": true,
  "valid": true
}
Enter fullscreen mode Exit fullscreen mode

No surprises at create time. If the image doesn't exist or the port is taken, you know before you've spun up half a stack.

Step 2: Container lifecycle

The Docker integration covers the full lifecycle:

  • Pull images from any registry (including private Docker Hub images via CLI)
  • Create containers with port mappings, env vars, volume mounts, restart policies, and custom networks
  • List, inspect, start, stop, restart, and delete containers
  • Auto-create the internal gatekeeper-internal network on first run

Step 3: nginx config generation

This was the part I hated doing by hand. Now it is one API call per site.

Gatekeeperd generates a complete nginx site config via POST /api/nginx/sites that includes:

  • auth_request gating that calls GET /api/gate/check on every request
  • A paywall fallback location that serves the 402 page when the auth subrequest returns 403
  • A bypass route for /api/gate/ so payment webhooks always reach the API
  • Proper proxy headers for WebSockets and real client IPs

Every config is validated with nginx -t before reloading via systemctl. If validation fails, the operation aborts and no changes are made. The service also auto-infers the upstream port from the container name (e.g., myapp:9921 → proxy to 9921).

Step 4: SSL automation

Certificates are handled through certbot via POST /api/ssl/certificates. The smart part is certificate resolution: the system checks for exact matches first, then reuses wildcard/parent domain certificates (e.g., example.com covers acw.example.com). Custom certificate paths and requireSsl enforcement are also supported.

The payment gate

This is the core of the platform. Every project has a status: ACTIVE or BLOCKED.

When a client is active, GET /api/gate/check returns 200 OK and nginx proxies traffic to the container. When they are overdue, it returns 403 Forbidden and nginx serves the paywall instead.

The paywall is self-service. The client sees their project name, the amount due, and a Pay Now button that redirects to Paystack. When payment succeeds, POST /api/payments/webhook fires, the project status flips to ACTIVE, and the Redis cache is invalidated instantly.

An auto-blocker runs on a schedule: if a project's due date passes and the grace period expires, it gets blocked automatically. If a payment is later reversed, the same webhook endpoint processes the chargeback event, re-blocks the project, and restores the due date. (Note: Reversal handling is implemented but not yet documented in the public API docs. Update pending.)

Lessons learned

Why nginx auth_request instead of app-level middleware?

Middleware would require modifying every client app. auth_request keeps the gating logic entirely in the reverse proxy layer. Client apps stay untouched—they don't even know Gatekeeperd exists. This means I can gate a Rails app, a Node API, or a static site with zero code changes on the client side.

Why Redis for gate status?

Every HTTP request to a gated site hits GET /api/gate/check. Without caching, that's a PostgreSQL query per request per client. Redis with a 60-second TTL absorbs the read load while keeping block/unblock propagation fast enough that clients see changes within a minute.

Why shell out to Docker CLI instead of using the Docker Engine API?

The Docker Engine API is comprehensive but verbose. For private registry pulls with credential helpers, the CLI handles auth flow, credential storage, and platform negotiation out of the box. Shelling out to docker pull was simpler and more reliable than reimplementing credential helper support in Kotlin. Trade-off: slightly slower, but deployment is not a hot path.

Why Ktor?

I needed a lightweight, coroutine-native HTTP framework that could handle long-running Docker operations without blocking threads. Ktor's routing DSL made the API surface readable, and its test infrastructure let me mock nginx/Docker interactions cleanly. Coming from Django, the explicitness of Ktor was refreshing—no magic, no implicit middleware chains.

Certbot automation gotchas

Certbot's --nginx plugin assumes it owns the nginx config. Since Gatekeeperd generates configs programmatically, I had to use standalone/webroot modes and manage certificate paths manually. Also, rate limits are real: staging certificates during development saved me from hitting Let's Encrypt's production limits repeatedly.

Webhook reliability

Paystack webhooks can arrive out of order or duplicate. Idempotency keys on payment records prevent double-unblocking. Signature verification on every webhook payload is non-negotiable—never trust the source IP alone.

Try it yourself

Gatekeeperd is completely open source. If you're interested in self-hosted deployment platforms, automated client hosting, or payment-gated SaaS infrastructure, I'd love feedback and contributions.

👉 GitHub: github.com/mikesplore/gatekeeperd

The stack: Kotlin + Ktor, PostgreSQL, Redis, Docker, nginx, certbot, Paystack, and a React admin dashboard. Deploy it on any VPS where you have root access and Docker installed.


Built by a freelancer who got tired of chasing invoices and writing nginx configs at 2 AM. May your deployments be boring and your payments automatic. 🛠️

Top comments (0)