I run my side projects on two to three small VPSs. For a while I used self-hosted deployment tools for them, and they all ended up feeling like a dashboard around Docker rather than a managed platform. Deploying a single app on a single server worked well, however everything beyond that was manual:
- A separate build server meant setting up my own registry, credentials on every server and often a tunnel between them.
- A second server meant Docker Swarm or custom routing between hosts.
- Builds for common frameworks needed hand-written Dockerfiles or config to work well.
launchroom is my attempt at the platform I wanted: build servers, multiple servers and framework builds handled by the platform itself, on hardware I own - kind of a “managed PaaS” feeling, but self-hosted. It's open source at https://github.com/vantezzen/launchroom.
This post goes through how the internals work and why they're designed the way they are.
Overview
launchroom has two parts:
- The dashboard (Laravel, Postgres, Valkey) holds all configuration, plans deployments and renders what each server should run.
- The daemon launchroomd (Go) runs on every server, including the one hosting the dashboard. It builds images, runs containers, configures Caddy and reports back what it observed.
Each server can have different “roles”: build, runtime and edge. With one server, that server has all of them. With more servers, you can for example build on a machine at home and run on two VPSs, both of which serve traffic.
Installation
One thing that also annoyed me was that many self-hosting dashboards kind of take over your whole server. The actual repo code they pulled is somewhere deep in /etc or /var, with various background services and containers started somewhere.
Instead for launchroom I decided that all core containers are in just a single transparent compose project, all the local files are simply in a ./launchroom directory and all containers that launchroom creates get a launchroom.managed=true label. This way, you can continue using your server for everything else without some hidden connections and you can just stop all those containers and remove that one folder to install it.
Defining projects
For projects I didn’t want to manually fiddle with Docker compose files or special syntax, so I got inspired by Railway to create a project canvas instead.
In the canvas you can simply add new nodes and connect them visually to connect their network and exchange the credentials.
The UI uses React Flow which was super nice to work with.
Desired state instead of remote commands
An early decision I made looking at other self-hosting codes, was that the dashboard can't run commands on servers. There is no SSH, no command endpoint and launchroomd listens on no public port. My reasoning was that the dashboard is a web app holding full power, and if it could run commands on every server, any bug in it would be root on all of them.
Instead, the dashboard build a desired state document per server, the NodeState. It describes everything the server should run: networks, volumes, containers, routes and firewall rules. launchroomd fetches it, verifies it and converges the server towards it. A shortened example:
{
"kind": "NodeState",
"version": 1842, // monotonic per server; older versions are rejected
"issuedAt": "2026-10-17T12:00:00Z",
"workloads": [{
"id": "wl_…",
"image": "127.0.0.1:5000/apps/svc_…@sha256:…",
"args": ["node", "server.js"], // argv, never a shell string
"env": { "sealed": "<crypto_box_seal(json env, daemon x25519)>" },
"health": { "type": "http", "path": "/", "expectStatus": "2xx-3xx" },
"role": "serving" // serving | draining
}],
"routes": [{ "hosts": ["shop.com"], "upstreams": [{ "workload": "wl_…", "port": 3000 }] }]
}All connections go from launchroomd to the dashboard. The dashboard signs the exact response bytes with Ed25519, and launchroomd checks the signature before parsing the JSON. This avoids having to canonicalise JSON for signing. For the same reason, launchroomd stores the original bytes and signature on disk instead of its parsed copy, and verifies them again after a restart.
// daemon/internal/state/store.go
func VerifyNodeState(raw []byte, signature string, v Verifier, serverID ServerID) (*SignedState, error) {
kid, err := v.Verify(signature, raw)
if err != nil {
return nil, fmt.Errorf("state: %w", err)
}
ns, err := DecodeNodeState(raw) // unknown fields are rejected
// …
if ns.ServerID != serverID {
return nil, &RejectedError{Version: ns.Version, Err: fmt.Errorf("state: NodeState is for server %s, this server is %s", ns.ServerID, serverID)}
}
return &SignedState{State: ns, Raw: raw, Signature: signature, KeyID: kid}, nil
}Environment variables are sealed with libsodium's crypto_box_seal to the receiving server's X25519 key. The sealed data includes the server and workload id, so a sealed blob can't be reused for another workload.
launchroomd internally polls every 30 seconds to the main dashboard, however when you quickly want to deploy this can feel slow. To make deploys feel immediate, the dashboard also sends a WebSocket message without any content that tells launchroomd to fetch now. In my dev setup, a click on "Deploy" reaches the server in about 1.4 s.
The NodeState has no expiry. The version counter already prevents replaying an older state, and with an expiry, apps would eventually stop while the dashboard is down. Without one, servers keep running the last state they verified: health checks continue, crons keep running because launchroomd schedules them itself, and Caddy restarts from its saved config.
Reconciliation then is a pure diff between the desired state and what Docker reports, followed by applying the plan:
// daemon/internal/reconcile/diff.go
// Diff computes the plan that converges actual to desired at time now. It is
// pure: no I/O, deterministic output order.
func Diff(d Desired, a Actual, now time.Time, grace time.Duration) Plan {launchroomd doesn't know about deployments or rollouts. The dashboard expresses a rolling update as new workload ids next to the old ones marked as draining, and launchroomd converges ids. This keeps all deployment logic in the dashboard, where it's easier to test and change.
Restricting Docker access
launchroomd needs Docker to create containers, and access to the Docker socket is equivalent to root on the host. To keep a bug in launchroomd, or one of its dependencies, from becoming host root, launchroomd doesn't get the actual Docker socket either.
A second binary, dockerd-guard, owns the socket instead. It runs without network access, without capabilities and with a read-only filesystem. That daemon serves launchroomd a socket that accepts an allow-listed subset of the Docker Engine API. Everything else is refused, including build, commit, export, attach, kill, prune, events and plugins.
Filtering by path isn't enough: For example Docker’s POST /containers/create can contain "Privileged": true to start a priviliedged container. To prevent a server takeover with this, the guard decodes request bodies strictly and forwards a re-encoded copy of the struct it checked, not the original body:
// daemon/internal/guard/handle.go
func decodeStrict(r *http.Request, v any) error {
// … content type and 1 MiB size checks
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return deny("request body: %v", err)
}
if dec.More() {
return deny("request body: trailing data")
}
return nil
}Go's encoding/json matches keys case-insensitively, and Docker's decoder could resolve duplicate keys differently. A body like {"Privileged": false, "privileged": true} could be read differently by the guard and by Docker. Re-encoding means we can be sure Docker really reads the final object the same way we did.
When theguard creates a container, it always adds the launchroom.managed=true label. For operations on existing containers, the guard first inspects the container, checks for the launchroom.managed=true label, and forwards the request using the full id from that inspection, so we can prevent taking over unrelated containers.
Both launchroomd and the guard also enforce a local policy, configured from a file launchroomd can't write. It covers the case where the dashboard itself is compromised and sends a validly signed state:
// daemon/internal/policy/policy.go
if hc.Privileged {
add("privileged", "privileged containers are not allowed")
}
if nm := string(hc.NetworkMode); nm == "host" || strings.HasPrefix(nm, "container:") {
add("namespaces", "network mode %q is not allowed", nm)
}
if len(hc.Binds) > 0 || len(hc.VolumesFrom) > 0 || hc.VolumeDriver != "" || len(cfg.Volumes) > 0 {
add("mounts", "bind mounts, volumes-from, volume drivers and anonymous volumes are not allowed")
}The policy also restricts routing. Route upstreams may only be mesh addresses or entries from a local allow-list, so a compromised dashboard can't route public traffic to Caddy's admin API, a database on localhost or a cloud metadata endpoint.
Connecting servers
With one server, no private network is needed. When a second server is added, the dashboard enables Headscale, which is a self-hosted Tailscale server. Every server then has its own Tailscale client inside a Docker container, so it can communicate with all other instances. Servers behind NAT, like a machine at home, only need outgoing access to the main server on port 443, where Headscale's DERP relay also runs - so there’s no need for Cloudflare Tunnels, open ports etc.
However, being on the mesh doesn't grant anything by itself since anybody could just join some network. Headscale's ACL denies new clients by default, and each server additionally filters traffic arriving on tailscale0 with nftables. To authenticate servers, the dashboard gives out a temporary secret the new server can use, and can then exchange for its own signed certificate being added to the Dashboard.
On fun thing the Tailscale network allowed is routing custom service names between servers: For example, in a project the main api service might be on one server, while the connected postgres service in on a different one. With a local Docker network or Docker Swarm you could connect to the postgres service just using the hostname “postgres ”, but how to replicate this on a custom Tailscale network?
If api on server B connects to postgres on server A:
- Server A publishes Postgres only on its mesh IP, on a port from 31000–32999 allocated by the dashboard, for example
100.64.0.7:31001. - Server A's nftables only allow that port from the mesh IPs of servers running workloads of the same environment. WireGuard ties a source IP to a peer key, so the source address can be trusted. The rule sits in
preroutingat priority −150, before Docker's DNAT at −100, so it still sees the published port. - On server B, launchroomd runs an “ambassador”: a small TCP forwarder attached to the environment's network with the alias
postgres. The app connects topostgres:5432as before.
There is one timing issue with this: A new replica on another server can pass its health check before the database server has applied the firewall rule that allows it. In that case, its health endpoint answers, but every request that needs the database fails. So the dashboard only counts a replica as healthy once every server it connects to has reported a NodeState version that includes its mesh IP.
Routing traffic with several edge servers
To allow a server that’s in the DNS routing to fail, launchroom also allows defining multiple “edge servers” that receive the traffic, handle TLS and then route requests to the actual servers hosting them.
Every edge server routes every service. Replicas on the same server are reached directly. Replicas on other servers are reached through their mesh address and published port. DNS can point at any edge, and for subdomains the dashboard recommends a CNAME to the service's generated hostname, so adding or replacing edges needs no DNS change.
Certificates need some coordination with several edges: With two or more shared edges, every edge's Caddy uses the dashboard's Valkey as shared certificate storage, over the mesh, with a Valkey ACL user limited to the commands Caddy's storage module needs. Caddy then coordinates ACME challenges between edges. Each launchroomd also mirrors the certificates it serves to local disk, so an edge can restart with valid certificates when the storage is unreachable.
Custom domains use Caddy's on-demand TLS. Caddy asks an endpoint whether it may get a certificate for a hostname, and that endpoint is answered by the local launchroomd from its cached routes, not by the dashboard. This keeps certificate issuance working while the dashboard is down and stops random SNI names from using up Let's Encrypt rate limits.
Build servers and moving images
Images are stored in a Zot registry on the dashboard host, bound to loopback so it cannot just be reached publicly as local Docker images may contain secrets and private code. Docker accepts plain-HTTP registries on 127.0.0.0/8 without any insecure-registries setting, so every server pulls from 127.0.0.1:5000.
On the dashboard host that's Zot itself, however on every other server it's a forwarder in launchroomd that passes the connection over the mesh. Nothing changes /etc/docker/daemon.json or ~/.docker/config.json since the registry is handled for each container, and pull credentials are bearer tokens valid for five minutes, passed per pull.
A build server pushes through the same forwarder. This way a home server behind NAT is usable as a build server for a VPS: it only needs its mesh connection.
The dashboard picks a build server for each build:
- Platform: the image is built for the platform of the servers the service will run on. A build server can also build platforms its Docker engine emulates. For example, an Apple Silicon Mac with OrbStack reports
linux/amd64through Rosetta. Native builders are preferred. - Server: the server that built the service last, if it's online and has a free slot, because BuildKit's mount caches are local. Otherwise the least busy build server, with
max(1, cores / 4)slots each. - Fallback: a build pinned to a server that doesn't pick it up within 90 seconds goes back to the pool.
Images are reused where possible. The build key is a hash of everything that affects the build: commit, root directory, builder settings, build-time variables and target platform. If an image with that key exists, the deployment skips the build. Changing a runtime variable redeploys the existing image, and promoting from staging to production reuses the staging image unless their build-time variables differ.
Building common frameworks
Before the first build, a plan step clones the repo and uses heuristics to categorize the code: framework, package manager, runtime version and where each came from, build and start commands, and port. A static scan for environment variable references (process.env.X, import.meta.env.X, os.environ) lists variables that the code uses but that aren't defined yet.
For each app, the builder is chosen in this order:
- An explicit choice in the service settings or a
launchroom.jsonin the repo. - A
Dockerfilein the repo. launchroom warns if it looks like a development setup, for example aCMDrunningnpm run dev. - A launchroom preset for the detected framework, on top of Railpack.
- Plain Railpack.
launchroomd runs Railpack through its BuildKit gateway frontend against its own BuildKit, using the BuildKit Go client, without the Docker CLI or buildx.
launchroom also contains presets that fix things Railpack's defaults don't cover for running in a container: binding to 0.0.0.0, setting PORT, and warning about adapters that don't fit, such as a SvelteKit app using adapter-static for server-side rendering.
Next.js has the most specific handling. For Next 16.2 and later, launchroom injects its own deployment adapter through NEXT_ADAPTER_PATH, without changing the repo. The adapter is vendored into launchroomd and checked by hash, so nothing is fetched from npm during the build. It forces standalone output, sets Next's deploymentId to the launchroom deployment id so asset URLs stay consistent across replicas, and copies public/ and .next/static next to server.js. The final image only contains .next/standalone and the Node toolchain. For the test fixture (Next 16.4, arm64), the image is 692 MB with the adapter and 1,083 MB with Railpack's default next start.
Builds on small servers
My most common setup is one small VPS running the dashboard, the apps and the builds. The first time I built a Next.js site on a 1 CPU / 2 GB droplet, the build used all the memory and took the other services down with it.
launchroomd now sizes BuildKit from its own measurements of the host. The dashboard can't change these values; only the server owner can override them.
// daemon/internal/build/buildkitd/resources.go
const (
controlPlaneMB = 1024 // reserved for the dashboard stack on its host
perWorkloadMB = 256 // reserved per running app (at most its limit)
minBuildMB = 512
buildCPUShares = 64 // Docker's default is 1024
buildBlkioWeight = 10 // the lowest block I/O weight Docker accepts
buildOOMScoreAdj = 1000 // the highest OOM score adjustment
// …
)- On a 2 GB server that also runs the dashboard, builds get the 512 MB minimum.
- A CPU weight of 64 lets builds use the full CPU when the server is idle but gives running apps priority otherwise.
- On one- and two-core servers, a hard CPU limit of
cores − 0.25keeps a quarter of a core free. - The OOM score adjustment of 1000 is inherited by every build step, so under memory pressure the kernel kills the build instead of actual production containers that are also running.
- Node builds get a heap limit of 75% of the build memory plus swap, so V8 collects garbage more often instead of the kernel killing the process.
Detecting stuck rollouts
A deployment of one of my own apps once stayed at "Roll out" for over 15 minutes with no logs and no error. I couldn't reproduce that exact case later, but looking into it turned up several problems that could each cause it:
- A reconcile pass waited for its image pull to finish, for up to 45 minutes. During that time, the server applied no newer state.
- Before pulling, launchroomd requests the image manifest to warm the registry's pull-through cache, because dockerd only waits 30 seconds for response headers. That request had no timeout of its own.
- The dashboard allowed 600 seconds for a pull, measured from the latest report. Since every report sent "still pulling" with a new timestamp, that limit never ran out.
- The UI showed a workload as running when its deployment was active, without any server having reported a container.
Pulls now run in the background. A reconcile pass waits at most 15 seconds for a pull, reports the workload as pulling and converges everything else:
// daemon/internal/reconcile/pulls.go
wait := time.NewTimer(r.o.PullWait) // 15 s
defer wait.Stop()
select {
case <-bg.done:
case <-wait.C:
return false, nil // still pulling; the next pass picks it up
case <-ctx.Done():
return false, nil
}Reports now include a pull.updatedAt that only changes when the pull makes progress. If no data arrives for two minutes, the deployment fails with a message naming the server, the registry it was pulling from, whether the mesh connection is direct or relayed, and how much was downloaded. Each rollout step has its own timeout, and the UI only shows a workload as running once launchroomd has reported its container. launchroomd also sends container lifecycle events (created, exited with code, out of memory, health check failed, stopped), which appear in the deployment and service logs.
Vulnerability alerts
launchroom builds every image itself, so it knows what each service runs. That makes it possible to match the packages in running images against vulnerability databases. The problem is noise: most advisories in a typical Node project are in dev dependencies, browser-only code or build tools, and an alert that fires all the time gets ignored (See https://overreacted.io/npm-audit-broken-by-design/).
launchroom has two levels. "Action needed" sends a notification. "Worth knowing" is a quiet list. I decided that a finding is only "Action needed" if all of these apply:
| # | Rule |
|---|---|
| A1 | The package is in the image of a live deployment outside a preview environment |
| A2 | It can run while the app handles requests (runtime scope, not dev or browser) |
| A3 | The severity is high or critical |
| A4 | A fixed version exists |
| A5 | It can be exploited over the network |
| A6 | The service has a public domain |
If a vulnerability is on CISA's list of known exploited vulnerabilities, most of these rules no longer apply, but A1 and A2 still do. An exploited bug in a dev dependency still can't be triggered by a request.
The scope in A2 is the hard part. launchroom only sets a scope from evidence:
- It reads the lockfiles and walks the dependency graph from the production dependencies. Packages only reachable from
devDependenciesare dev. - For Next.js, it searches the build output for
node_modules/<name>/references. Found in.next/servermeans runtime; found only in.next/staticmeans browser. - Without evidence, the scope is "unknown", and unknown packages only raise an alert if the vulnerability is known to be exploited.
OS packages from the base image only raise alerts if they're on a short list of runtimes and web servers, such as Node, Python, PHP and nginx. OpenSSL isn't on the list: TLS ends in Caddy, so app containers receive plain HTTP, and OpenSSL in the app only handles outgoing connections.
There is deliberately no central launchroom service that pushes advisories, firewall rules or patches to installations. It would be a single point through which an attacker could reach every installation, so each dashboard downloads public data itself (OSV, which includes GitHub's advisory database, plus CISA's known-exploited list and endoflife.date), matches it locally and decides locally. A manipulated feed can at most cause a wrong alert. The cost is that urgent fixes always wait for a signed release.
The web firewall
launchroom's Caddy build includes the OWASP Coraza firewall with the OWASP Core Rule Set, enabled for every service by default. The dashboard only sends typed settings per route (mode, paranoia level and rule exclusions), and launchroomd renders the actual directives from a template in its binary. A compromised dashboard can't add its own directives.
I measured the overhead in a Docker-in-Docker test on an arm64 laptop. A GET request without a body costs about 0.5 ms. Bodies were more expensive than expected: a 64 KiB JSON POST originally added about 0.25 s of CPU time, and a single 1 MiB form field about 3 s. The cost depends on the number and length of fields, not the body size, because every rule runs on every field with its transformations. Each field costs about 0.3 ms, and Coraza inspects up to 1,000 fields by default. With these defaults, a single unauthenticated request could use seconds of CPU on the edge server that all apps share.
The defaults now limit inspection to the first 32 KiB of a body and the first 256 fields. Larger bodies pass through instead of being rejected, so uploads keep working:
SecRequestBodyLimitAction ProcessPartial # data beyond the limit is passed through
SecArgumentsLimit 256 # inspect at most 256 fields
SecAction "id:10002,phase:1,pass,nolog,ctl:requestBodyLimit=32768"The most expensive request I found with these settings costs about 0.1 s. The trade-off is that an attacker who knows the limits can put a payload after 32 KiB of padding or after 256 harmless fields. Because of this, the firewall filters common automated attacks, but it doesn't replace fixing the app.
Firewall events include the client address. launchroomd truncates it to the /24 (IPv4) or /48 (IPv6) network directly after parsing the event, so full addresses never leave the server:
// daemon/internal/state/waf.go
func ClientNetworkOf(a netip.Addr) ClientNetwork {
a = a.Unmap().WithZone("")
bits := 48
if a.Is4() {
bits = 24
}
p, _ := a.Prefix(bits)
return ClientNetwork(p.String())
}Limitations
- launchroomd still has host privileges. It uses host networking and
NET_ADMINfor nftables and the mesh. Removing Docker socket access reduces the risk, but a compromised launchroomd can still change the host's firewall. - Secrets are visible on the host. Environment variables are sealed in transit and on disk, but
docker inspecton the server shows them. I don’t think this can be solved without changing the application code inside the containers as these env vars will always be accessible somehow. However, the outer server should be trusted so this shouldn’t be an issue - The dashboard isn't highly available. Apps keep running while it's down, but deployments and configuration changes wait for it.
- One platform per deployment. Each deployment has one image digest, so mixed amd64/arm64 setups run each service on one platform. Multi-platform builds aren't implemented.
Conclusion
I’m quite happy with how launchroom turned out! I still need to iron out some issues during deployments, especially on multi-server setups, but generally the dashboard is a pleasure to use.
I’ve been running launchroom for some of my projects for a little while now. If you also want to try it for yourself, visit https://launchroom.vantezzen.io/.
