← Case studies

Case study 04 · 3 min read

dlt

Self-service site provisioning, built at home, then rebuilt for production

Role
Designed and built it for my own homelab
When
Sep 2025 – Aug 2026
Stack
Laravel 13BashDocker ComposeCloudflare APInginxcertbotMariaDBPest

The provisioning UI isn't public yet; a sanitized copy is being prepared. The template it wraps is public.

TL;DR
  • One form provisions DNS, a reverse-proxy vhost, a TLS certificate and a full Compose stack, with live step-by-step logs.
  • Edge and local work run as two parallel queue jobs. Every proxy change is gated on `nginx -t`, and DNS and certificate steps are safe to re-run.
  • It launched 11+ sites. What I learned here became dev-llc, the production version.

The problem

Every new site on my homelab needed the same chores:

  • SSH to the reverse-proxy VM and write a vhost
  • run certbot
  • add a Cloudflare record
  • find a port that nothing else was using
  • generate a Compose stack

I started with a script, setup.sh config/foo.env, that handled the last part: one .env file in, a complete Laravel + nginx + MariaDB stack out. The other steps were still manual and easy to get wrong, so I wrapped the whole thing in a small internal platform.

This is the home-lab half of a two-part story. The ideas I proved here were later rebuilt for real clients in dev-llc, which puts each client on its own cloud server.

Architecture

What one form submission does
Form subdomain, ports, site type
then
Edge job DNS
then
vhost
then
certbot
then
reload
Form subdomain, ports, site type
then
Local job setup.sh
then
Compose stack
then
Laravel
Where traffic ends up
Browser
then
Cloudflare proxied DNS
then
Reverse-proxy VM nginx + Let's Encrypt
then
Docker host nginx
then
PHP-FPM
then
MariaDB

The edge job creates a proxied DNS record (only if it's missing), writes the vhost on the proxy VM over SSH, runs certbot --keep-until-expiring, and reloads nginx only after nginx -t passes. The local job writes the site's env file with randomly generated database passwords, then runs the template script and streams its output into the step log.

The progress page polls every 2.5 seconds and shows each step's badge and log, then either an error panel or a "site is live" panel.

Key decisions

Two jobs in parallel. DNS propagation and certificate issuance don't need the app built, and a Laravel build takes minutes. Running them side by side cuts the wait, and separate steps make it obvious where a failure happened.

Never break the proxy for everyone else. One reverse proxy serves every site. Every change is tested with nginx -t before a reload (not a restart), and certbot's --keep-until-expiring means a re-run doesn't burn Let's Encrypt rate limits.

Three sources of truth for free ports. Ports are checked against Docker's published ports, every proxy_pass in the proxy VM's configs (fetched over SSH), and a live TCP probe. Each source alone misses cases, like a stopped container whose proxy rule still exists. A conflict warns rather than blocks, so I can override it on purpose.

The database stays private by default. The template strips the MariaDB host-port mapping unless a site explicitly asks for it.

Two kinds of site. A full Laravel site, or a "bare" site that only gets DNS, the proxy and TLS, for off-the-shelf software that brings its own container.

A golden path, not just a script. Every generated site gets the same layout, a shared set of in-house packages mounted read-only, and a design-token theme system documented in a style guide and a theme contract. New sites start consistent instead of drifting.

What broke and how I fixed it

The template grew out of things going wrong. A one-off script became set -euo pipefail with readiness polling, because composer create-project would run before the containers were up. File ownership is aligned to the host user and the web server's group, because files created inside the container kept ending up owned by root. The automated DNS, proxy and certificate steps exist because I'd forgotten one of them by hand more than once.

The bigger lesson came in production. dlt assumes one server, one DNS zone and one token. The first time I designed for a real client, almost every one of those assumptions broke. That's the story of dev-llc.

Results

  • 11+ sites launched from it, including several of my own apps, a domain-only site for remote desktop support, and dlt itself.
  • Five demo sites for fictional local businesses (a car wash, a used-car lot, a pizza place, a library and a photographer), each with its own theme. They show what the platform produces end to end.
  • Pest feature tests cover the wizard, both site kinds, validation and env generation.
  • The template is public: Dockerized_Laravel_Template.