Getting started, 2 of 3 · ← Introduction · Next: Deploy your first app →
This is the one part you do over SSH. It takes about five minutes: put the program on your server, create your login, and start it. After this, everything happens in the dashboard.
- A Linux server with
systemd(any cheap VPS works). - Docker installed, with the Compose plugin (
docker compose) — how to install it is just below. - Caddy — the managed HTTPS edge supervises a child Caddy for
:80/:443+ automatic certificates. It isn't bundled (it's third-party, like Docker);mooring setupinstalls it for you (below), or the.deb/.rpmpulls it in if you've added Caddy's apt repo. - SSH access to the server.
- 1 GB of RAM or more if you want to deploy and build apps on the box. Monitoring and HTTPS run fine on a smaller server.
Why doesn't Mooring just install these itself at runtime? Because the running service is deliberately unprivileged — it can't install packages, edit host DNS, or grant capabilities. That's the security model: a compromised dashboard must not be able to either. So prerequisites are a one-time, admin-run, root job — which
mooring setupmakes a single command (it runs as you over SSH, not as the service).
mooring setup installs Caddy for you, but Docker is deliberately left to you. It's your host's container runtime and root-equivalent trust base, and installing it — choosing storage/cgroup drivers, daemon config, group membership — redefines the box in ways a control plane shouldn't decide on your behalf. So set it up once, up front. (mooring doctor will report Docker as missing until you do.)
If both of these already print a version, you're set — skip ahead:
docker --version
docker compose version # note the space — the v2 "Compose plugin", not the old docker-composeIf not, install the engine plus the Compose and Buildx plugins (Mooring needs all three — it builds images with BuildKit). On Ubuntu, using Docker's official apt repo:
# 1. Add Docker's official GPG key
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# 2. Add Docker's apt repo
sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
# 3. Install the engine + plugins
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginOn Debian, replace both
ubuntuoccurrences above withdebian. Fedora / RHEL / other distros: follow https://docs.docker.com/engine/install/. In a hurry on Debian/Ubuntu and don't need the newest engine? The distro's own packages work too:sudo apt install docker.io docker-compose-v2 docker-buildx.
Then confirm the Compose plugin is live — docker compose version should print v2.x. That's all the Docker you touch — from here, Mooring drives it for you over a locked-down, read-only connection.
apt install mooringwith nothing else only works for packages that ship in Debian/Ubuntu's own repositories (that's whyapt install pythonjust works). Mooring is third-party, soaptneeds to be told where to find it once — exactly like installing Docker, Chrome, or Tailscale. Pick one:
Quickest — install the .deb directly. Download the .deb for your architecture from the latest release, then:
sudo apt install ./mooring_<version>_linux_amd64.debapt pulls in any dependencies, creates the mooring service user, and installs the systemd unit — so you can skip Step 4. To update later, download the newer .deb and run the same command.
Seeing
N: Download is performed unsandboxed as root … couldn't be accessed by user '_apt' … Permission denied? That's a harmless note, not a failure.apt's unprivileged sandbox user can't read files in your home directory (it's mode0750), soaptcopies the.debas root and the install completes anyway — confirm withdpkg -l mooring. To avoid the note, install from a path_aptcan read (download into/tmp), or skip the sandbox withdpkg:sudo dpkg -i mooring_<version>_linux_amd64.deb && sudo apt-get install -f
Best for updates — add the signed APT repo (once). Then sudo apt upgrade keeps Mooring current automatically, and every download is signature-checked:
curl -fsSL https://daboss2003.github.io/mooring/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/mooring.gpg
echo "deb [signed-by=/usr/share/keyrings/mooring.gpg] https://daboss2003.github.io/mooring stable main" | sudo tee /etc/apt/sources.list.d/mooring.list
sudo apt update && sudo apt install mooring(Fedora/RHEL: a matching .rpm is on each release — sudo dnf install ./mooring_<version>_linux_amd64.rpm (or _linux_arm64.rpm).)
Any other Linux — the raw binary. Grab the binary for your architecture from the releases page and put it in place (update by replacing the file). With this option you also do Step 4 to create the service:
install -m0755 mooring /usr/local/bin/mooring
mooring versionMost host prep is automatic. The package's systemd unit + postinstall already create the
runtime dir (/run/mooring) and the writable state dirs, grant the one capability the edge needs
(CAP_NET_BIND_SERVICE, so Caddy/nginx bind :80/:443/:53 non-root), set a writable HOME
and a sane MemoryMax, and leave the egress filter off by default. You don't apply drop-ins or
tune the unit.
There are only a few one-time steps you do by hand — mooring doctor tells you if any are
missing (it's read-only; run it any time to verify the box):
mooring doctor # read-only: reports anything off + the exact fix
sudo mooring setup --yes # installs the Caddy binary — the managed edge needs itMooring runs its OWN supervised Caddy (and nginx for L4). The packaged
caddy.service/nginx.servicewould squat:80/:443and crash-loop Mooring's edge (you'd see a greendoctorof old but a cert failure at deploy: "the edge has not issued the TLS cert yet").mooring setupnow disables those distro units whenever they're present — even if you installed Caddy/nginx yourself beforehand. If you ever add them later, runsudo mooring setup --yesagain (orsudo systemctl disable --now caddy nginx).mooring doctorflags an active/enabled distrocaddy/nginxas a conflict with the one-line fix.
That's everything for a normal HTTPS app. Two extras apply only in specific cases:
-
A non-HTTP service on a privileged port (a DNS resolver on
:53, MQTT, …)? Install the L4 load balancer's nginx + stream module, and — only if you bind:53— free it fromsystemd-resolved(Mooring never rewrites host DNS for you, because getting it wrong takes the box's own DNS down):sudo mooring setup --l4 --yes # nginx + the stream module # then, ONLY for a :53 resolver, free the port from systemd-resolved: printf '[Resolve]\nDNSStubListener=no\n' | sudo tee /etc/systemd/resolved.conf.d/no-stub.conf sudo systemctl restart systemd-resolved sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf # the real upstreams — NOT stub-resolv.conf getent hosts github.com # confirm host DNS still resolves
-
(Recommended) Cap Docker's container logs. Mooring streams logs (it never buffers them in memory or its DB), but Docker's default
json-filedriver keeps every container's stdout on disk forever — on a small VPS that fills the disk.mooring setupcaps it for you (merges amax-sizeinto/etc/docker/daemon.json, preserving your other settings, then restarts Docker). That restart bounces running containers, so it's a reviewable step in the plan — runsetupbefore you deploy apps and it's a no-op disruption. The equivalent by hand:{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }then
sudo systemctl restart docker(or use the self-rotatinglocal/journalddriver).
When mooring doctor is all-green, the host is ready — there's nothing else to tune.
These commands create the secrets Mooring needs. You run them over SSH — never in a browser — so your master key and password never travel anywhere they shouldn't. They print what to paste into your config in the next step.
# A master key that encrypts your stored secrets. Back this up somewhere safe.
mooring gen-key
# Your dashboard password (you'll be prompted to type it; it's never shown or logged).
mooring hash-password
# Optional: turn on two-factor sign-in. Prints a QR code to scan with your
# authenticator app, plus the secret to paste into your config below.
mooring gen-totpKeep your master key safe. It's what unlocks your stored secrets. Save a copy somewhere separate from the server. If you lose it, the encrypted data can't be recovered.
/etc/mooring/config.yaml is the only file you edit by hand. It holds the essentials: your key, your login, who's allowed to reach the dashboard, and your email for HTTPS certificates. There's no command that generates it for you — it contains your master key and login, so you write it over SSH.
If you installed the .deb/.rpm/apt package, the directory already exists and a template is on disk — copy it and edit:
sudo cp /usr/share/mooring/config.example.yaml /etc/mooring/config.yaml
sudo nano /etc/mooring/config.yaml # or: sudo vi /etc/mooring/config.yamlFill in the essentials (below), then lock the file down — Mooring refuses to boot if it's group/world-writable or unreadable by the service:
sudo chown root:mooring /etc/mooring/config.yaml && sudo chmod 0640 /etc/mooring/config.yamlThe file:
# /etc/mooring/config.yaml
bind_addr: "127.0.0.1:9000" # the dashboard is private — only reachable as below
encryption_key: "<paste from gen-key>"
ip_allowlist: # who may reach the dashboard (your IP / VPN)
- "203.0.113.10/32"
auth:
username: "operator"
password_hash: "<paste from hash-password>"
# totp_secret: "<paste from gen-totp>" # optional two-factor
# Optional: additional operators with roles (leave out for a single-user setup).
# users:
# - username: "alice"
# password_hash: "<paste from hash-password>"
# totp_secret: "<paste from gen-totp>" # optional
# role: deployer # owner | deployer | viewer
edge:
mode: "managed" # Mooring runs the web server + HTTPS for you
acme_email: "you@example.com" # used by Let's Encrypt for your certificates
admin:
hostname: "admin.example.com" # the dashboard's address; point its DNS at this server
data_dir: "/var/lib/mooring" # where Mooring keeps its dataSet admin.hostname to the address you want the dashboard on, and point that hostname's DNS at your server — Mooring serves it over HTTPS, behind your IP allowlist. If you use the subdomain shorthand (edge.base_domain), you can instead set just admin.subdomain: admin and it becomes admin.<base_domain> — covered by the same wildcard DNS record as your apps:
edge:
base_domain: mooring.example.com
admin:
subdomain: admin # → admin.mooring.example.com (or use admin.hostname for a full name)(Prefer not to expose it at all? Leave both out and reach the dashboard over an SSH tunnel instead — see the next guide.)
The auth: block is your primary operator — always an owner. To let others in, add a users: list; each entry is minted the same way (mooring hash-password, and optionally mooring gen-totp) and given a role:
| Role | Can |
|---|---|
| owner | Everything — deploy, plus all admin actions: delete apps, reveal secrets, run setup scripts, manage backups, connect repos/GitHub, rotate tokens, reclaim disk. |
| deployer | Deploy, roll back, start/stop/restart, and edit an app's env, config files, and scaling. Not the admin actions above. |
| viewer | Read-only — see apps, logs, metrics, events, and status. No changes. |
Roles are enforced on the server for every action (a viewer who tries a deploy gets a clear "forbidden"), and each user has their own password and 2FA. Changing, adding, or removing any user logs everyone out (they log back in) — so a removed operator's sessions end immediately. There's no self-service UI: like every other secret, users live in the root-owned config file, and a systemctl reload mooring picks up a change.
When you expose the admin dashboard, it becomes internet-reachable — gated by your
ip_allowlist(enforced at the edge and re-checked against the real client), then your password + TOTP. Keepip_allowlisttight (never a catch-all), and enable TOTP. Mooring fronts the dashboard through the managed edge to a dedicated internal listener (admin.edge_listen, default127.0.0.1:9001) so your SSH-tunnel access onbind_addrkeeps working unchanged. Exposing/unexposing needs a restart (listeners are fixed at boot). If you use GitHub connect, re-register your OAuth App's callback ashttps://<admin hostname>/github/callback.
By default each declared subdomain gets its own certificate via HTTP-01. If you run many
subdomains (or want a name that isn't HTTP-reachable), you can instead issue one
*.<base_domain> wildcard via ACME DNS-01 — no per-name rate limits, one cert for
everything under base_domain:
edge:
base_domain: mooring.example.com
dns01:
provider: cloudflare # your DNS provider (cloudflare, route53, digitalocean, …)
api_token: "<dns-api-token>" # secret — a token scoped to edit this zoneMooring installs the right Caddy plugin for you — you don't build anything. DNS-01 needs
your provider's DNS module compiled into Caddy, so on startup Mooring runs caddy add-package
(Caddy's official mechanism: it downloads a plugin-enabled binary and swaps it in) for the
provider you named. It supports every provider in the caddy-dns
family — Cloudflare, Route 53, DigitalOcean, Google Cloud DNS, Azure, Hetzner, Vultr, Linode,
Namecheap, and more. (If the auto-install can't run — e.g. the box can't reach caddyserver.com
or the caddy binary isn't writable by the service — Mooring logs a clear warning and the edge
still comes up; the wildcard just won't issue until the plugin is present. Nothing is ever
mis-issued.)
With dns01 set, subjects at/under base_domain are served by the one wildcard; hostnames
outside it keep their own HTTP-01 cert. The api_token is stored only in the root-only
config.yaml (never logged or shown in the dashboard) and passed solely to the supervised Caddy.
More than one app on this box? Add named
edge.base_domains(each with its own optionaldns01) so colocated apps — prod, staging, … — each keep thesubdomain:shorthand under their own apex without colliding. See namespaces.
Mooring validates this file at startup. If a required value is missing or the file permissions are too open, it stops with a clear message explaining what to fix.
Everything else about your apps lives in the dashboard (or an optional per-app file). This config is just the foundation.
The package's postinstall already created the
mooringservice user, the data directories (/var/lib/mooring,/var/lib/mooring-apps,/var/lib/caddy),/etc/mooring, and the systemd unit. Do not run the manual commands below — they're for the raw-binary install only, and running them produces harmless-but-confusing errors likeuser 'mooring' already existsandcannot stat 'config.yaml'. Once your config from Step 3 is in place, just start it:sudo systemctl enable --now mooring sudo systemctl status mooring # → "active (running)"Then jump to the verification at the end. (Run everything with
sudo— a plainsystemctl enablewill hang on a polkit password prompt.)
Only if you installed the raw binary (Step 1's last option — no package, so nothing was set up for you) do you create the service account, directories, and unit by hand. Run these with sudo, from a checkout of the repo so deploy/systemd/mooring.service exists:
sudo groupadd --system mooring
sudo useradd --system --no-create-home --shell /usr/sbin/nologin --gid mooring mooring
sudo usermod -aG docker mooring
sudo install -d -o root -g mooring -m0750 /etc/mooring
sudo install -d -o mooring -g mooring -m0700 /var/lib/mooring
sudo install -d -o mooring -g mooring -m0700 /var/lib/mooring-apps # per-app run dirs
sudo install -d -o mooring -g mooring -m0700 /var/lib/caddy # edge data/cert store
sudo install -m0644 deploy/systemd/mooring.service /etc/systemd/system/mooring.service
# (write /etc/mooring/config.yaml per Step 3, then:)
sudo chown root:mooring /etc/mooring/config.yaml && sudo chmod 0640 /etc/mooring/config.yaml
sudo systemctl daemon-reload && sudo systemctl enable --now mooring
sudo systemctl status mooringThat's it. You won't run any Docker commands — Mooring sets up everything it needs to talk to Docker (a locked-down, read-only connection) and runs your HTTPS edge itself. From here on, it's all in the dashboard.
/etc/mooring/config.yaml is read at startup. After you hand-edit it, Mooring won't notice the change on its own — you have to tell it to pick it up, and how depends on what you changed:
| You changed… | Apply with |
|---|---|
Who can reach the dashboard (ip_allowlist, trust_proxy, trusted_proxies), your login (auth.username, auth.password_hash, auth.totp_secret), or log retention (retention.*) |
sudo systemctl reload mooring — hot-applied, no downtime |
Anything else — the master encryption_key, bind_addr, edge.* (incl. base_domain, dns01, l4_enabled), admin.* (incl. subdomain, edge_listen), github.*, alerting.*, session.*, cookie.*, docker.*, protected_projects, … |
sudo systemctl restart mooring |
The rule of thumb: only the allowlist + login + retention are hot-reloadable; everything else is read once at boot and needs a restart. A reload that touches a restart-only setting will silently do nothing — so when in doubt, restart (it briefly drops the dashboard connection; your apps keep running).
Reload is safe: it validates the new file first and keeps the old config if the edit is invalid. One side effect to know: enabling/rotating two-factor (
auth.totp_secret) on reload logs you out, so you re-authenticate with the new factor.
(Most app settings — env, routes, scaling, self-healing, ops — aren't in this file at all; you manage them in the dashboard, which applies them live. This file is just the bootstrap essentials.)
Mooring is now running and serving HTTPS. Nothing is published yet — that happens when you add an app and give it a domain.
Next: Deploy your first app →
Want the security and hardening details of the service file (memory caps, sandboxing, network egress lock-down)? See How it works & why it's safe. Running your own Docker connection instead of the built-in one? Set docker.external_proxy: true — see the CLI reference.