I have a folder on my laptop called side-projects. Most of them are Dockerized. Most of them will never see more than a handful of users. And for years, every one of them hit the same wall: getting the thing onto a cheap VPS without losing a weekend — and copy-pasting my own past mistakes forward every single time.
Here's the opinion that eventually turned into a project: the part of a PaaS you touch most often should hold the least power over your server.
Think of a deploy tool as two planes. There's a read plane — dashboards, logs, container health, the stuff you stare at — where you spend most of your time and which is your most exposed surface. And there's a write plane — deploy, restart, the actions that actually change the system — which is rare and should be gated. My frustration was that the thing I looked at all day and the thing that could rewrite my box tended to sit on the same pile of privilege.
So I built Mooring to keep those two planes apart. It's an early, solo project, and this post is me showing it and asking for eyes on it.
The mental model
The whole thing, end to end:
- Install it as an unprivileged systemd service (not root).
- Connect a git repo.
-
Write one
mooring.yaml. - Click Deploy.
That's the loop. Everything below is what's underneath it.
What it is
Mooring is a small, security-first, self-hosted control plane for Docker — a tiny PaaS. You point it at a git repo, describe your app once, and it deploys and runs your containers on your own server. Same territory as Coolify, Dokku, CapRover, and Kamal — all genuinely good work. The difference I care about is the posture underneath.
It ships as one static, CGO-free Go binary. It runs as an unprivileged systemd service — not root. State lives in SQLite (the pure-Go modernc driver). Every asset is embedded with go:embed, so there's no node_modules, no asset pipeline, nothing to build on the box.
To be honest about the neighborhood: setting up a self-hosted PaaS, these tools tend to want broad access to your machine and your Docker daemon. And read-write access to the Docker socket is, for all practical purposes, root on the host. I'm running a personal VPS with my actual secrets on it, so that's the trade I wanted to change.
The read plane never holds a read-write Docker socket
This is the part I actually built the project around.
Mooring's read plane — every dashboard view that shows you what your containers are doing — talks to Docker through a read-only docker-socket-proxy. Those views never hold raw read-write access to the socket. The write plane (the stuff that changes your system) goes through a separate, controlled path instead of being blended into every page load. The surface you use 95% of the time is the surface with the least reach.
A few more things fall out of the same "assume this box matters" mindset:
- The admin web UI runs under a strict Content-Security-Policy with no inline JavaScript.
- Auth is password + TOTP 2FA, and changing your password, username, or TOTP revokes every existing session — an "auth-epoch" fingerprint — so a stolen session can't outlive the credential change meant to kill it.
- The managed images (the socket proxy, the edge, and friends) are digest-pinned.
- Files bind-mounted into your containers get least-privilege permissions.
- API tokens are scoped and CIDR-bound — restricted to the networks you name.
Deploy a Dockerized app without writing a Dockerfile
Here's a real (trimmed) mooring.yaml for one of my apps:
apiVersion: mooring/v1
kind: App
metadata:
slug: linkstash
spec:
compose:
source: generated # Mooring generates & owns the compose
services:
api:
build: # no Dockerfile to write — Mooring generates a hardened one
language: node
start: [node, dist/main]
ports: [{ internal: 3000 }]
env:
NODE_ENV: production
MONGODB_URI: { secret: MONGODB_URI } # a reference — the value stays in the store
secrets:
- name: MONGODB_URI
edge:
routes:
- hostname: api.example.com # Mooring terminates HTTPS and routes to api:3000
service: api
port: 3000
Three lines are worth pointing at:
-
source: generated— Mooring generates and owns the compose file. You don't hand-edit it. -
build.language: node— you name the language, and Mooring generates a hardened Dockerfile for you. (It supports Node, Python, Go, Ruby, PHP, static, and generic.) -
MONGODB_URI: { secret: MONGODB_URI }— that's a reference, not a value. The secret stays in the store; the plaintext never lands in the file.
And the part I'm actually proud of — what I did not write:
- No
Dockerfile. Mooring generates and owns a hardened one. - No
docker-compose.yml. Mooring generates and owns the compose. - No reverse-proxy config. A managed Caddy does automatic HTTPS and renews the certs — including internal certs for services like an MQTT broker.
- No cert-reload sidecar. Gone.
- No plaintext secret in the file.
Because Mooring owns compose generation, the genuinely dangerous compose keys — privileged, host mounts, host namespaces — can't even be expressed in mooring.yaml. There's simply no syntax for them in the schema.
The bits that made it pleasant to dogfood
The build system recently learned to detect your package manager from your committed lockfile — pnpm-lock.yaml → pnpm, yarn.lock → yarn (including Yarn Berry), else npm; poetry.lock → poetry, Pipfile → pipenv, else pip — and it strips dev dependencies out of the shipped image.
The gitops model is deliberately calm: connect a repo, and Mooring fetches changes on its own (no webhook to configure), but it never auto-deploys. You click Deploy. Push-to-deploy exists as an explicit opt-in. One repo can hold multiple apps via mooring.yaml + mooring.*.yaml.
On the ops side there's an auto-scaling edge load balancer, encrypted backups of Mooring's own state, a read-only Server tab (live CPU/mem/disk, top processes, and an allow-listed file viewer that refuses to show secrets or state), optional Trivy CVE scanning of your app images without mounting the docker socket into Trivy, and a self-update/advisory checker that alerts you even when the running Mooring version is itself the one affected.
The honest part
Mooring is young. It's solo-built, it's around v0.4.x, and right now its most serious user is me, dogfooding it on my own boxes. I'm not going to pretend it's battle-tested at scale — it isn't, yet. I'm also not going to overstate the guarantees: the claim is that the read plane doesn't hold a read-write socket and that dangerous compose keys can't be expressed, not that the whole thing is unbreakable.
But the core idea feels right, and I'd love other eyes on it — especially from people who care about the socket-access problem the way I do. If you run Dockerized side-projects on a VPS and any of this resonates, I'd genuinely value your feedback, your "wait, but what about X," and your early-user bug reports.
- Repo: https://github.com/daboss2003/mooring
- Docs & site: https://daboss2003.github.io/mooring/
If you kick the tires, come tell me what broke. That's the whole reason I'm posting.
Top comments (0)