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
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.