tooling

Building Bloat: a macOS developer runtime manager with Go and Tauri

Why we started building Bloat, why Docker stopped fitting our local workflow, and how the daemon, desktop app, DNS, TLS, and signed runtime manifests came together.

Like many engineering teams, we standardized on Docker for local development. At first it worked well, a few containers, a simple compose file, consistent environments. Then the stack evolved. What started as a Laravel application became a platform: multiple services, web applications, APIs, databases, caches, search engines, object storage, background workers, observability tooling. Each new capability added another container, another port, another configuration file, another layer of ambient complexity.

On macOS, Docker requires a Linux virtual machine. Every file change, every bind mount, every hot reload, every container startup crosses that boundary. As the stack grew, so did memory consumption, CPU usage, battery drain, and startup times. The tools that were supposed to simplify local development had become a significant part of the complexity developers had to manage. That is the irony of local infrastructure that has grown past its original scope.

We looked at solutions like Laravel Herd and appreciated the experience: install it, start services, get local domains, get back to building. Fast, native, invisible. But our needs extended well beyond PHP. We wanted something that could manage a full modern stack, Laravel, Node.js, PostgreSQL, Redis, search, object storage, and supporting infrastructure, without requiring Docker running all day.

That is where Bloat began. The name is intentional. Bloat is not about adding complexity. It is about containing it. It manages local development services as native macOS processes rather than containers, handles domains and service lifecycle, databases, logs, notifications, and updates, and stays lightweight enough that it can disappear into the background. Our goal was simple: developers should spend their time building products, not managing infrastructure.

The architecture decision: daemon first

The easiest mistake with a tool like this is building a UI that shells out to scripts. That approach produces a demo quickly, a button starts a process, another stops it, a settings page writes YAML, but the real problems show up within weeks. Durable state. Background lifecycle management when the UI is closed. A consistent event stream. Routing changes applied atomically. Logs that survive app restarts. Recovery after a crash. A trustworthy update path for runtimes like Postgres, Node, Traefik, mkcert, Mailpit, MinIO, and Typesense. At that point the UI is not the product. The control plane is.

Bloat became a daemon-plus-control-panel architecture early in the design process. The Go daemon, bloatd, owns the hard parts: process supervision, persisted state, local HTTP and WebSocket APIs, DNS and certificate orchestration, Traefik config generation, runtime install and rollback, notifications, and event emission. The Tauri desktop app is a client of that daemon, important, but not the source of truth. That separation made almost every later decision cleaner.

Why Go

Once the daemon needed to exist as a real long-lived process, Go became the natural fit. I wanted something comfortable being long-lived, with good concurrency primitives for supervision, updates, DNS, eventing, and API handling simultaneously, and a single static binary story for the orchestration layer. The daemon startup path in apps/daemon/cmd/bloatd/main.go reflects that directly, load config, initialize logging, open SQLite, run migrations, create an event bus, start a log pipeline, build the supervisor, initialize the proxy manager, certificate manager, updater, DNS manager, database manager, site manager, expose the localhost API server.

sqlite, err := db.Open(ctx, cfg.Database, logger)
if err != nil {
    return fmt.Errorf("init db: %w", err)
}
defer sqlite.Close()

if err := db.RunMigrations(ctx, sqlite, logger); err != nil {
    return fmt.Errorf("run migrations: %w", err)
}

events := eventbus.NewBus(128)
supervisor := process.NewSupervisor(cfg.Supervisor.StopTimeout, logger, events, process.WithLogPipeline(logPipeline))
serviceManager, err := services.NewManager(cfg.Services, supervisor, logger)

That code should be boring. I wanted the orchestration core to behave like infrastructure software, not like another frontend application pretending to be a system process. Go fits that disposition well.

What the daemon actually manages

bloatd acts like a local single-user platform controller. It is not just starting and stopping Postgres. It manages known services, site definitions, domain bindings, certificate metadata, database lifecycle, update operations, log indexing, and event delivery. That state lives in SQLite, not because SQLite is trendy, but because the scope is local, embedded, and single-node. The daemon opens the database with foreign keys enabled and a busy timeout, sets MaxOpenConns(1), and treats it as local control-plane state. The migrations make that role concrete: managed_services, domain_bindings, event_log, certs, databases, log_entries, notifications, update_operations, sites.

There is even an FTS5 index for logs:

CREATE VIRTUAL TABLE IF NOT EXISTS log_entries_fts USING fts5(
    message,
    service_name,
    content='log_entries',
    content_rowid='id'
);

That detail tells you something about the product boundary. Bloat is not only starting processes. It is trying to be the place where local runtime state is observable and searchable. If a service crashes, if a runtime update fails, if a site logs a particular error, the tool should surface that without making the developer dig through random files in ~/Library.

Native processes instead of containers

The core bet behind Bloat: if the host machine is already macOS and the services I want to run have macOS binaries, I would rather manage them as native processes than as containers inside a Linux VM. That does not mean containers are wrong, they are the right answer for many problems. It means they are not always the best answer for local development where you are already running macOS and paying a VM tax you did not need.

The Bloat runtime model is: download a known runtime artifact, verify it, install it into Bloat’s runtime root, register a service definition, and supervise it directly. No Docker daemon, no compose lifecycle as the control layer, no VM overhead for ordinary local workflows, no constant bind-mount penalties between host and guest. The site manager makes this concrete for application runtimes too, a site can be created for node, php, or python, and Bloat resolves the command from the installed runtime root, assigns a free port if needed, generates default args, persists the site, and registers it with the supervisor.

if req.Port == 0 {
    port, err = findFreePort()
    if err != nil {
        return Site{}, fmt.Errorf("find free port: %w", err)
    }
}

if len(args) == 0 {
    args = defaultArgs(req.Runtime, req.EntryPoint, port)
}

hostname := strings.TrimSpace(req.Hostname)
if hostname == "" {
    hostname = hostnameFromName(req.Name)
}

Tauri as the control surface

Once the daemon owned orchestration, the desktop app had a narrower and cleaner job: a native-feeling control panel for macOS, not the brain of the system. Tauri fit that better than a heavier desktop stack. I could keep a React UI, use Vite, move quickly on the interface, and ship something that felt like a macOS utility rather than a browser app trapped inside a desktop window.

The client talks to the daemon over localhost HTTP and WebSocket endpoints. HTTP for request/response, listing services, domains, databases, sites, kicking off updates. WebSockets for event streams and live logs. The default transport points at http://127.0.0.1:5080 and ws://127.0.0.1:5080/events/ws. That split keeps the desktop app simple and stateless. If the UI crashes, the daemon keeps running. If the daemon restarts, the client can reconnect. If a second client type is needed later, the system boundary is already correct.

Localhost is not a security model

One of the easiest mistakes in local tooling is treating “it only runs on localhost” as a security posture. It is not.

Bloat’s API middleware generates a session token, stores it under ~/Library/Application Support/Bloat/session.token, and requires it for everything except /health. The desktop client passes it as a bearer token for HTTP and as a query token for WebSocket upgrade paths.

if !a.authenticated(r) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusUnauthorized)
    _ = json.NewEncoder(w).Encode(map[string]any{"error": "unauthorized"})
    return
}

That might look like a small detail. It reflects a design principle: if a tool can start local databases, issue certificates, rewrite routing config, and install runtimes, it should behave like a privileged system, not like a convenience script.

DNS and HTTPS as first-class features

The most satisfying part of Bloat is the local networking model. I did not want developers to remember ports, or every project inventing a different local hostname convention, or HTTPS being optional in development when real applications increasingly depend on secure origins, callbacks, cookies, and browser behavior that differs under TLS.

Bloat uses an embedded DNS server bound to 127.0.0.1:53535, /etc/resolver/test so macOS routes *.test lookups through it, Traefik as the reverse proxy, and mkcert for local certificate issuance and trust. The DNS manager persists domains in SQLite and exposes lifecycle operations through the daemon. If the resolver file is missing, the daemon warns with an install hint rather than silently failing, respecting the operating system boundary instead of hiding it.

For routing, the daemon renders Traefik configuration from runtime state rather than implementing HTTP proxying itself. The dynamic config template starts from service and site definitions, generates static and dynamic config files, and coordinates proxy sync. Traefik is the right choice here because it is designed to react safely to config changes rather than requiring fragile imperative shell operations around a config file.

Signed runtime manifests

This is where Bloat moved from “developer convenience tool” to “something that mutates your machine and therefore needs a security model.”

The updater reads runtime manifests, downloads artifacts, verifies checksums, verifies signatures, unpacks archives, and installs binaries into the runtime root. The manifest format includes runtime identity, platform and architecture, artifact URL, SHA-256, signature, signature algorithm, install file mapping, and an optional healthcheck command. The scope covers Postgres, Redis, Traefik, SQLite tools, mkcert, PHP, Node, Mailpit, MinIO, and Typesense.

var (
    ErrSignatureMissing       = errors.New("artifact signature is required")
    ErrUpdaterPublicKeyAbsent = errors.New("updater public key is required for signature verification")
)

Artifacts use Ed25519 signatures over the SHA-256 hex digest. That is an explicit trust boundary that is visible in the codebase. Without it, a “runtime manager” is a nicer-looking curl pipe. A tool that downloads and installs binaries should have a verification story that is obvious, not incidental.

LaunchAgents for persistence

For Bloat to feel like a real macOS tool rather than a terminal session, the daemon needed to survive login sessions. Bloat includes LaunchAgent installation scripts that register dev.bloat.bloatd under ~/Library/LaunchAgents, configure RunAtLoad and KeepAlive, set working directory and log paths, and bootstrap the agent with launchctl. The difference between “you have to remember to start this” and “it is always running when you need it” is enormous in daily developer experience. Good tooling should reduce ceremony, not introduce new ceremony.

Observability inside the tool

Local tooling that knows what is happening but forces you to reconstruct the picture manually anyway is a failure of the concept. The daemon already supervises services, it already sees startup, shutdown, crashes, and routing state. Exposing that information directly is the obvious thing to do.

The daemon builds a log pipeline rooted under ~/Library/Application Support/Bloat/logs, broadcasts live entries, indexes them into SQLite for search, and emits events through an in-memory bus to WebSocket clients. That makes it possible to build a UI that is an actual window into the local runtime rather than just a set of start/stop buttons.

What building Bloat taught me

The biggest lesson: local developer tooling becomes substantially better once you stop thinking of it as a wrapper around commands and start thinking of it as infrastructure software with a native control surface. The right architecture followed from that realization, Go for the daemon, Tauri for the UI, SQLite for control-plane state, Traefik for routing, mkcert for local trust, LaunchAgents for persistence, signed manifests for runtime distribution.

The other lesson is about the problem space itself. When developers say “local setup is annoying,” they are usually not complaining about one command. They are describing an environment whose operational model has become too visible, too many things requiring manual attention, too many processes to remember, too many ports and hostnames and certificates to track. Bloat is an attempt to push that complexity below the waterline. Not by pretending it does not exist, but by giving it one place to live.