How I turned a single MacBook into a private cloud — AI inference, media server, dev services, wildcard HTTPS — all managed as code across 19 Docker Compose profiles.
That number sounds absurd until you realize what it actually means in practice. I'm not running 116 web servers. I'm running one shared Postgres instance, one Redis-compatible cache, one S3-compatible object store, one observability stack, one AI inference runtime, one streaming pipeline, and one auth server -- all of which are shared across six active projects. The 116 figure is the total container count across a single docker-compose.yml that's 3,425 lines long and covers 19 named service profiles. The machine is a MacBook M1 Max with 64 GB of RAM. On any given working session, maybe 30-40 of those containers are actually running.
The story isn't "I run a lot of containers." The story is why I built this instead of just paying for cloud services.
I was running six projects simultaneously -- karmpath, prachyam-sangam, hotel-elegent, kanishka-creations, sutradhaar, and my portfolio -- each with its own isolated Docker stack. Each one had its own Postgres, its own Redis, its own MinIO. That's 18 to 30 containers of pure duplication, each configured slightly differently, each with its own credentials. But the real problem wasn't RAM or disk. It was the HTTP/HTTPS gap. In development, everything ran on http://localhost:PORT. In production, everything ran on HTTPS. That difference breaks OAuth redirects, secure cookies, service workers, and payment iframes -- all features that you can't test properly without deploying to staging first. I was spending time debugging staging instead of building. That's when I decided to fix the environment instead of working around it.
The core idea is simple: one docker-compose.yml file, 19 named profiles, and a rule that no project ever starts a service it doesn't need.
Karanveer Singh Shaktawat
Full Stack Engineer & Infrastructure Architect
Building portfolio, contributing to open source, and seeking remote full-time roles with significant technical ownership.
Pick what you want to hear about — I'll only email when it's worth it.
Did this resonate?
Self-hosting is not about being cheap or contrarian. It's about understanding your stack, owning your data, and building a certain kind of engineering judgment that you can't get any other way.
How I architected a full-stack OTT streaming platform for web, mobile, four TV OSes, and a desktop admin panel — solo, in one Nx TypeScript monorepo — and what I'd do differently.
# docker-compose.yml (excerpt)
services:
postgres:
image: local/postgres-pgvector:17
profiles: [core]
labels:
docsee.service: postgres
docsee.profile: core
docsee.description: "Shared PostgreSQL 17 + pgvector for all projects"
docsee.domain: "pgbackweb.dev.dharmic.cloud"
typesense:
image: typesense/typesense:26.0
profiles: [search]
ollama:
image: ollama/ollama:latest
profiles: [ai]When I'm working on karmpath, I run docker compose --profile core --profile search --profile messaging --profile devtools up -d. When I switch to the OTT project, I add --profile media --profile streaming --profile monitoring. Services that aren't needed stay down. RAM usage stays proportional to what I'm actually doing.
The 19 profiles are:
core -- postgres (pgvector), pgbouncer, dragonfly (Redis-compatible), minio + minio-init, pgbackweb, timescaledb, falkordb, hocuspocussearch -- typesense, qdrant, meilisearchmessaging -- nats (JetStream), soketi, centrifugo, redpandaworkflows -- temporal, temporal-uimedia -- imgproxy, varnish, ffmpeg, shaka-packager, openinaryai -- rembg, ollama, open-webui, langfuse (web + worker), libretranslatemonitoring -- prometheus, grafana, alertmanager, loki, tempo, otel-collector, uptime-kumastreaming -- mediamtx (RTMP ingest + HLS output)analytics -- clickhouse, redpanda, posthog, openpanel, metabaseautomation -- n8ndocs -- outlinecustomer -- chatwoot (setup + rails + sidekiq)email -- hyvor-relayauth -- authentik (server + worker)notifications -- ntfyplatform -- umami, tolgee, bugsink, flipt, shlink, listmonk, altchadevtools -- mailpit, drizzle-gateway, redis-commander, bullmq-board, gotenberg, browserless, bytebase, cloudbeaverpayments -- hyperswitch, killbill + kaui, lago, cal.cominternal-tools -- appsmith, hasura, directus, novu, gitea, woodpecker, sonarqube, vaultwarden, jitsi, portainer, openreplay, infisicalThe profile system is what makes the whole thing usable. Without it, I'd be back to maintaining separate compose files per project -- which is how I ended up with credential sprawl in the first place.
There's also a ports.yml -- a 254-line machine-readable YAML file that is the single source of truth for every port in the stack. Every service has a registered port. Every project has a registered set of ports for its frontend, API, and worker processes. No port hunting. No conflicts. Adding a new service means adding one block to ports.yml before touching the compose file.
Every service in the stack is reachable at *.dev.dharmic.cloud. No ports in URLs. No browser security warnings. Real TLS certificates. This is the feature that makes the entire setup feel like production.
Getting there requires solving three problems in the right order.
First, DNS. I can't edit /etc/hosts for every service -- there are 97 named routes and the number grows. Instead, dnsmasq runs locally with a single wildcard rule:
# dnsmasq/dnsmasq.conf
address=/.dev.dharmic.cloud/127.0.0.1
address=/.co.dharmic.cloud/192.168.139.236 # OrbStack Coolify VM
address=/.do.dharmic.cloud/192.168.139.168 # OrbStack Dokploy VMOne rule covers every current and future subdomain. New service added to Caddy? It's immediately reachable at its domain without touching dnsmasq.
Second, TLS certificates. The standard HTTP-01 ACME challenge doesn't work for localhost -- there's no public internet reachability to verify. DNS-01 does. Instead of proving I control the domain by serving a file at /.well-known/acme-challenge/, I prove it by creating a TXT record via the Cloudflare API. Caddy handles this automatically with the acme_dns cloudflare directive:
# caddy/Caddyfile (excerpt)
*.dev.dharmic.cloud {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
}
@karmpath host karmpath.dev.dharmic.cloud
handle @karmpath {
reverse_proxy localhost:3000
}One wildcard certificate. 97 subdomains. Auto-renewed. Zero browser warnings.
Third, routing. Caddy reverse-proxies each named host to its container port. The Caddyfile is 855 lines covering application projects, storage, search, messaging, observability, streaming, auth, AI, dev tools, analytics, payments, scheduling, and communications. A few routes need special handling -- Hocuspocus and LiveKit both require WebSocket-aware header forwarding:
@hocuspocus host hocuspocus.dev.dharmic.cloud
handle @hocuspocus {
reverse_proxy localhost:3455 {
header_up Connection {http.upgrade}
header_up Upgrade {http.upgrade}
}
}Without those two lines, WebSocket handshakes fail silently. That took longer to debug than I'd like to admit.
The result is that every project running on this machine gets HTTPS on day one. OAuth redirects work. Secure cookies work. Service workers work. I've stopped deploying to staging just to test auth flows.
The ai profile runs Ollama, Open WebUI, Langfuse, rembg, and LibreTranslate.
Ollama exposes an OpenAI-compatible API at ollama.dev.dharmic.cloud. Any project that would otherwise call api.openai.com can point at Ollama instead. The switch is a single environment variable. Inference stays on-device, off the network, and costs nothing per call. For development, this matters: I can iterate on prompts hundreds of times without watching a credit balance drop.
Open WebUI gives me a browser interface backed by Ollama -- think ChatGPT but local and with full model control. Langfuse sits in front of everything and traces every prompt: input tokens, output tokens, latency, cost (estimated), and evaluation results. When a prompt starts behaving unexpectedly, Langfuse is where I look first.
rembg is a background removal REST API -- useful for the media processing pipeline in prachyam-sangam where user-uploaded images need transparent PNGs generated. LibreTranslate handles offline subtitle translation for the OTT project, which targets regional-language content in India where cloud translation APIs would introduce both latency and per-character cost at scale.
None of this is AI-as-a-buzzword. These are specific tools solving specific problems in active projects. The local-first constraint is deliberate: I don't want my development workflow dependent on external API availability, rate limits, or billing.
The entire repo was built in a single evening. Eight commits, 105 minutes, from 21:09 to 22:51 on 2026-03-28. The confidence to do that came from running a similar setup at Prachyam Studios -- a 3-machine Tailscale mesh where dnsmasq and Caddy provided *.dev.prachyam.local HTTPS routes across an iMac and two Docker hosts. That experience validated the pattern. This is the personal evolution of it: collapsed to one machine, extended from a project-specific OTT stack to a full personal SaaS toolkit.
The DNS-01 challenge discovery was the unlock that made everything else possible. Once you understand that wildcard TLS on localhost is a solved problem -- it just requires a DNS provider with an API and a Caddy plugin -- the "HTTPS in dev" problem disappears permanently. It's one of those things where you feel slightly annoyed that nobody told you sooner.
The ports.yml registry was the right abstraction from the first day. The cognitive overhead of tracking which ports are taken across 6 projects and 116 containers was already painful at 3 projects. Having a machine-readable source of truth meant I could also write MIGRATE.md -- a migration guide structured as instructions for Claude Code to read directly. Onboarding a new project to the shared infra now takes one AI-assisted session: read MIGRATE.md, reconcile credentials, restructure the compose file, register the profile. Around 20 minutes instead of an afternoon.
What surprised me was how much of the value came from the documentation, not the containers. The docsee.* labels on every container -- docsee.service, docsee.profile, docsee.description, docsee.domain -- let MetriX and DocSee (two other tools I'm building) read the Docker socket and discover the full service list dynamically. Adding a new container automatically registers it with monitoring. No manual dashboard updates. That lightweight label schema was a 10-minute addition that continues paying forward.
What I'd do differently: add a Makefile earlier. Right now, starting profiles is a raw docker compose command. make up PROFILES=core,search would be cleaner. I'd also derive the Coolify and Dokploy VM IPs from OrbStack's API programmatically rather than hardcoding them in dnsmasq.conf. Static IPs in config files are technical debt waiting to bite.
umbrelOS is trying to give normal people a personal cloud -- a box you put in your closet that runs your apps, your files, your services, behind a clean UI. What I've built here is functionally the same thing, without the box, without the UI, and with 19 times more services than umbrelOS ships with.
The goal isn't the same. I'm not building a product for non-technical users. But the impulse is identical: own your stack, know where your data lives, don't be a tenant in someone else's infrastructure. The commercial SaaS alternatives to what I'm running locally -- Sentry, PostHog Cloud, Metabase Cloud, Intercom, Cloudinary, GitHub Actions, 1Password Teams -- would collectively cost hundreds of dollars per month. The local versions cost electricity.
There's a version of this that ships as a product. umbrelOS is one attempt. Coolify and Dokploy are others. What none of them have yet is the production-parity networking layer -- the wildcard TLS, the named domains, the four-tier topology from one compose file. That's the part that makes local infrastructure feel real instead of provisional. It's also the part that took the most thought to get right.
The stack runs. The HTTPS is real. The services talk to each other the same way they will in production. That's what matters.