Server, on the home box
One script, run as root on the home box. It asks what the cloud is called, who the first account is, where it goes, which addresses it answers on, which database it keeps its records in and which standard quills it has, then clones the repo, builds a venv, writes the config and the systemd unit, generates the sealing key, starts the service, creates that account as administrator and gives the server an agent of its own.
curl -fsSL https://raw.githubusercontent.com/Cloudmorrow/cloudmorrow/main/deploy/install-server.sh | sudo sh
The questions are asked on the terminal, not stdin, so the script can arrive through a pipe. Every answer has a flag, and with all three given nothing is asked, which is what a script or a re-run wants:
sudo sh deploy/install-server.sh \
--name "The Larsens" \
--public-url https://cloud.example.com \
--user alice # password from $CLOUDMORROW_ADMIN_PASSWORD, or asked
Add --dry-run to read what it is about to do without doing any of it, or
asking anything. --repo deploys a fork; from a checkout it defaults to
that checkout's origin, and on its own to the public repository.
Installing from a private fork instead: give --repo an ssh URL and the script
does the deploy-key dance — the first run generates a read-only key and stops,
you add it under the repository's deploy keys, and the second run does the
install. (Cloning by hand first cannot work: that is the access the key is
about to grant.)
What it leaves behind:
| path | |
|---|---|
/opt/cloudmorrow/src |
the git checkout the service runs from |
/opt/cloudmorrow/venv |
its virtualenv, installed editable so updates are a pull |
/etc/cloudmorrow/server.toml |
the config — yours to edit, never rewritten |
/etc/systemd/system/cloudmorrow.service |
runs as the cloudmorrow system user |
/usr/local/bin/cloudmorrow-update |
the updater, below |
/usr/local/bin/cm |
the terminal app, here on the server too (cloudmorrow beside it is the same command) |
/etc/sudoers.d/cloudmorrow |
lets --admin run exactly what the updater needs: the update as the service user, the restart, and linking cm |
/var/lib/cloudmorrow/.ssh/ |
the deploy key the service pulls with, if the repo needed one |
/etc/cloudmorrow/cloudmorrow.key |
the key everything is sealed with at rest |
/etc/cloudmorrow/postgres.pgpass |
the database password, when the installer ran PostgreSQL in a container |
Re-running the script is safe: it puts the checkout on the remote branch,
reinstalls, and leaves your config, notes, database and accounts alone. It
reads the name, address and database back out of the existing config
rather than asking again, and asks for an account only while the server
has none. On a server that is running and answering it asks first
whether to update it, and Enter says yes; --update skips the question,
and without a terminal a server that is well is left as it is.
The terminal app is on the server too. cm at the box opens it, you
sign in with your own name and password, and an administrator has
Administration on the top bar, as on any other machine. It needs no
address: a client on the server with no config of its own reads where the
server listens off /etc/cloudmorrow/server.toml (or wherever
CLOUDMORROW_SERVER_CONFIG says) and reaches it there — loopback when it
answers there or on every address, else the first address it was given —
whichever addresses were chosen at install, and whether or not a proxy
and a public_url sit in front. Plain http to an address the server here
listens on is this machine, so nothing has to be allowed. cm login --server, or cm config set api_url, points it somewhere else as on any
machine, and then that config wins. Signing in on the server enrols no
agent: the box has the server's own already, and it is the machine's.
The database
The records, the accounts and everything else that is not a file live in
one database, and the installer asks which: the built-in one, a SQLite
file at /var/lib/cloudmorrow/cloudmorrow.db, which is right for a
household or a company and needs nothing else on the machine; or
PostgreSQL. --database container runs PostgreSQL beside the server as
the container cloudmorrow-postgres (docker or podman), with a password
made once and kept at /etc/cloudmorrow/postgres.pgpass, and a dump of it
written into /var/lib/cloudmorrow/postgres/ every day by
cloudmorrow-pgdump.timer, so whatever backs up the data directory
carries the database with it. --database postgresql://… is a server you
already run, reached once before anything is written. Either way the
answer is database_url in server.toml, blank for the built-in one; the
server reads CLOUDMORROW_DATABASE_URL in a container. The choice is made
at install: the records do not move from one database to the other.
HOSTING.md, The database, says what each is for.
The cloud's name
name in /etc/cloudmorrow/server.toml — what the installer's first
question wrote — is on the sign-in screen, the install page, the phone's
home screen (through the manifest and the Apple title), the page title, and
/api/health. It is text, never markup, and blank means "Cloudmorrow".
Change it by editing the config and sudo systemctl restart cloudmorrow; a
phone that already has the app on its home screen keeps the name it was
added under until it is added again.
First boot in the browser
Skip the account question, or run the installer with no terminal, and the
server is set up on its first visit instead. While it has no accounts,
/, /install and /app all redirect to /setup: one form for the
cloud's name and the first account, posted to /api/setup, which makes
that account the administrator and sends the browser to the app. The
moment any account exists the page redirects to /app and the endpoint
answers 409, so there is nothing to find afterwards. /api/health says
"setup": true until then, for a client that wants to send somebody there.
The name set this way lives in the database, and wins over the one in the
config file: a running service cannot write /etc, and a hosted tenant
has no file to edit. An administrator changes it later with
PATCH /api/server/settings and {"name": "…"}; anybody signed in may
GET it. HOSTING.md is where this leads: a tenant somebody
bought is "open it and fill in the setup page".
As a container
deploy/docker/ builds the server into one image, with /data and
/keys as its two volumes and every config key an environment variable.
compose.yml puts Caddy in front of it for the certificate, and the first
visit is the setup page above. The database is the built-in one in the
data volume unless the postgres profile is on, which runs PostgreSQL as
a third container on a volume of its own (COMPOSE_PROFILES=postgres POSTGRES_PASSWORD=… docker compose up -d), or CLOUDMORROW_DATABASE_URL
names a server of your own. Updating is replacing the image, so
allow_api_update is off inside it, and FORWARDED_ALLOW_IPS=* (the
same as trusted_proxies = ["*"]) lets uvicorn believe a proxy that is
another container rather than the loopback. The wheel the image was built from is copied into /data/dist
on every start, so /install.sh hands out the code that is running.
Accounts can also be made from the server at any time; the first one on a server is its administrator whatever flags it is given:
sudo -u cloudmorrow /opt/cloudmorrow/venv/bin/cloudmorrow-server user create sam
--password-stdin reads the password from stdin instead of prompting, for
a script, and user list --count prints how many accounts there are, which
is how the installer knows whether to ask.
Moving an install from BramCloud
Everything was renamed at once — the package, the commands, the paths, the
environment variables, the units — so an existing install carries on as
BramCloud until you move it. Do not run the old bramcloud update server
against the renamed code: it would pull it and then restart a unit whose
command no longer exists. Instead, on the server:
sudo sh deploy/migrate-from-bramcloud.sh --public-url https://cm.hl.bramlabs.io --admin jimmi
It stops the old units, moves /etc, /var/lib, /opt and the database to
their new names, renames the system user, rewrites the paths and public_url
in the config, fetches the renamed code and hands over to
install-server.sh, which writes the new unit, updater and sudoers file and
rebuilds the venv. The signing, secrets and VAPID keys move with the data
directory, so accounts, tokens, secrets and push subscriptions survive; notes
stay where notes_dir points.
Then, on each machine, sh deploy/migrate-client.sh https://cm.hl.bramlabs.io
installs the cloudmorrow client, keeps your sign-in and project links, and
retires the old bramcloud one. The phone app stores its token under a new
key, so it asks you to sign in once more.
The URLs
Three places, and only the first two matter for a normal setup:
-
public_urlin/etc/cloudmorrow/server.toml—https://cm.hl.bramlabs.io. The install page bakes this into the command it hands out, so it must be the address other machines use, not the bind address. -
your reverse proxy — point it at whatever
--host/--portthe service listens on. On one box that is127.0.0.1:8787:cm.hl.bramlabs.io { reverse_proxy 127.0.0.1:8787 }The server listens on every address by default, so
http://<its IP>:8787answers on the network too, and a proxy on a different machine reaches it directly (--host 127.0.0.1keeps it to a proxy on the same box;--host "127.0.0.1, 192.168.1.5"answers on both, and the installer offers the machine's addresses as boxes to tick):cm.hl.bramlabs.io { reverse_proxy 100.71.157.19:8787 }A proxy on a different machine also has to be trusted, or the server takes everything through it for plain http — refused with 426 once
public_urlis https:trusted_proxies = ["100.115.4.117"] # the proxy's address, as the server sees itAddresses or CIDR ranges, or
"*"; the loopback is always trusted, so a proxy on the same box needs nothing.--trusted-proxyon the installer writes it. What a trusted proxy says inX-Forwarded-ProtoandX-Forwarded-Foris believed; from anyone else those headers are ignored. One thing needs neither: the address the install page shows, the script bakes in andcmsigns in to is taken fromX-Forwarded-ProtoandX-Forwarded-Host(orForwarded) whoever sent them, because they change only the address handed back to the one who asked. So an untrusted proxy still hands out anhttps://command; it just cannot let the sign-in through until it is trusted orpublic_urlis set. -
each client —
cloudmorrow config set api_url https://cm.hl.bramlabs.io, which the install script does for you.
Change public_url later by editing the config and
sudo systemctl restart cloudmorrow.
Answering only the proxy
On a machine that also serves other things, a host firewall can be more trouble
than it is worth. allowed_client_ips narrows the API instead:
allowed_client_ips = ["192.168.10.88"] # just the reverse proxy
Addresses or CIDR ranges, matched against the real TCP peer — never
X-Forwarded-For, which the caller sets and could therefore forge. Everything
else gets a 403, including the install page and login. Empty (the default)
allows anyone who can reach the port.
It does not replace the login: whatever can reach the proxy can still reach the API through it. It stops everything else on the LAN from talking to the port directly.
Updating after you push
Push, then deploy. From your laptop, if your account is an admin:
cloudmorrow update server
That goes through the API — no shell on the server, no ssh agent, nothing to type but the command. The server puts its own checkout on whatever the remote branch says, reinstalls in case dependencies moved, rebuilds the client wheel, and restarts onto the new code; the command waits for it to come back and prints the commit it came back on. With nothing new to pull it says so and stops.
So a release is two things:
python scripts/release.py # tag this commit, push the code and the tag
cloudmorrow update server # the server runs it, and republishes the wheel
cloudmorrow update all does that second line and the cloudmorrow update that
usually follows it, in one go.
Versions
The version is a git tag. Nothing writes it down — pyproject.toml reads it
back off the nearest vX.Y.Z behind HEAD — so there is no number in a file
to go stale and no way for it to disagree with the history it claims to
describe.
One bump per push: every push of new code is a release and takes the next
minor, which is what scripts/release.py does. A major is the only thing
anyone decides:
python scripts/release.py # 0.6.0 → v0.7.0
python scripts/release.py --major # 0.6.0 → v1.0.0
python scripts/release.py --dry-run # say what it would do
It refuses on a dirty tree, and refuses to tag a commit that is already a
release — a push with nothing new in it is not one. The tag and the commits go
up together (git push --follow-tags), so the remote never holds code whose
release has not arrived.
Between releases the version says so: a commit past v0.7.0 builds as
0.8.0.dev3+g1c91c8b — the release being worked towards, how far past, and
which commit. Anything with .dev in it is not a release, and the deploy
output names it by its commit rather than dressing it up as one.
The commit hash has not gone away, and should not: a version only changes when
a tag does, so it cannot prove a process restarted onto new code. That is what
/api/health still reports a commit for, and what cloudmorrow update server
compares when it waits for the server to come back. The version is what you
read; the commit is what is checked.
Nothing on the server watches git, by design — a push on its own deploys nothing, and the deploy is a thing you do at a moment you choose.
What restarts it, given the service has no privileges. The service user
owns /opt/cloudmorrow and the deploy key, so the pull and the reinstall are
just it working on its own files. The restart it genuinely cannot do — so it
does not try: it stops itself, and systemd starts it again on the new code.
That is what Restart=always in the unit is for, and the server checks the
unit says so before it stops — otherwise a deploy would take the API down
until someone walked over to the box. systemctl stop cloudmorrow still stops
it for good; systemd knows the difference between that and an exit.
What it trusts. An admin token can change the code the server runs. That
is a real step up from what a token could do before, and worth deciding
deliberately rather than inheriting. What bounds it is git: the deploy is
git reset --hard origin/<branch>, so it can only run a commit that is
already pushed to the repository — not code of the caller's choosing. If that
is still more than you want a bearer token to do:
allow_api_update = false # in /etc/cloudmorrow/server.toml
and the endpoint answers 403 for everyone.
The way in when the API is the problem. A bad deploy is exactly when you cannot use the API to fix it, so the ssh path stays:
cloudmorrow update server --ssh
or, from your own account on the server, no sudo password and no root shell:
cloudmorrow-update
Those drive the same update over ssh instead, authorised by your ssh access
plus the sudoers rule the installer wrote rather than by a token. --host
takes any ssh target when the API hostname is not the one you ssh to (an ssh
alias, a tailnet name); make it permanent with
cloudmorrow config set server_host <target>.
Either way it refuses to run if the checkout has uncommitted changes or if
history has diverged, rather than merging on your behalf; --force overrides
the first of those, --branch deploys something other than the current branch,
and --no-restart leaves the new code on disk with the old code still serving.
Under the hood the ssh path is cloudmorrow-server update, which also takes
--source if you need it. The checkout belongs to the service user, and git
refuses to touch a checkout owned by somebody else ("dubious ownership"), so
cloudmorrow-server update run as root does what cloudmorrow-update does:
the update as the owner, only the restart as root. Run as any other user it
stops and says whose checkout it is. Do not add the safe.directory
exception git suggests: files root writes there are files the service user
cannot update past.
Upgrading a server installed before any of this existed. The endpoint and
the Restart=always unit both arrive in an update, so the first one still
goes over ssh:
cloudmorrow update server --ssh # deploys the code that adds the endpoint
then re-run deploy/install-server.sh --update (it rewrites the unit and is
safe to run again) or edit Restart= in /etc/systemd/system/cloudmorrow.service by hand
and sudo systemctl daemon-reload. After that, cloudmorrow update server is
the API path. Until the unit is right the deploy still lands — the server just
reports that it did not restart itself, and says why.
cm on the server arrives the same way. An update installs the terminal app
into the server's venv, and puts it on the PATH beside cloudmorrow-server
when it may; from the API it runs as the service user, which owns the venv
and not /usr/local/bin, so it says cm installed on the server, but not on its PATH instead. Then either re-run deploy/install-server.sh --update,
which links it along with everything else, or make the one link yourself:
sudo ln -s /opt/cloudmorrow/venv/bin/cm /usr/local/bin/cm
Any run of cloudmorrow-server as root does the same, quietly, the first time.
Where the client comes from
Cloudmorrow is not on PyPI, so the server is where machines get it: the installer
builds a wheel from the deployed checkout into <data_dir>/dist, serves it at
/dist/…, and /install.sh installs from there. Every cloudmorrow-update
rebuilds it, so a machine installing tomorrow gets today's code.
cloudmorrow-server publish rebuilds it by hand; pass a .whl to publish one
built elsewhere.
The wheel keeps the same version number between commits, so /install.sh
installs with --force-reinstall — otherwise pip would decide the old copy
already satisfied it.
From docs/MANUAL.md in the Cloudmorrow repository. Something wrong? A pull request there fixes it here.