Installing Mob
This is the guide for somebody standing up their own copy of Mob for other
people to use. It assumes you are comfortable at a terminal. It does not assume
you know Ruby, or Rails, or anything about how Mob is built.
For a machine you will develop on rather than deploy, the README.md in the
source tree is the shorter path.
What you need
| A Linux machine | Two cores and 2 GB of memory is comfortable for a small team. Docker is installed for you where it is missing |
| Two names in DNS | Both pointing at that machine, before you start |
| Ports 80 and 443, open | 80 is not only for redirects — the certificates are issued through it |
That is the entire list, and a bare cloud image meets it. There is no Ruby to
install, no Postgres to set up and nothing to compile: everything Mob needs is a
container, and the whole installation is one file and one command.
What actually runs
Four containers, all of them pulled rather than built. You do not have to
understand them to install Mob, but knowing what is there makes the logs
readable and the failures obvious.
| Container | Image | What it is for |
|---|---|---|
caddy |
caddy:2-alpine |
TLS and the front door. Gets a certificate for each of your two names, renews them on its own, and passes requests inward. The only container with a port open to the world |
web |
ghcr.io/wibbly/mob |
Mob itself. Runs the database migrations and creates the uploads bucket as it boots, and runs background jobs inside itself |
postgres |
postgres:18 |
The database — all four of them. Version 18 specifically, for a function Mob uses to generate identifiers that older versions do not have |
minio |
pgsty/silo |
Blob storage: every uploaded file, avatar and custom emoji |
Two of them want a word: one because its name is misleading, the other because
you may wonder why it is there at all.
minio runs Silo, a maintained community fork of MinIO. MinIO Inc. archived
the community edition in February 2026, so the original image is frozen with
disclosed vulnerabilities that will never be fixed. The fork keeps the same API,
the same settings and the same on-disk format; the service keeps the old name
because renaming it would move data to buy nothing. It is pinned to an exact
image rather than a moving tag.
caddy is doing a job that is not optional, which is why it is here rather
than left to you. Mob has two public names, both need certificates, and one of
them is the blob store rather than the app — see below. Caddy is the smallest
thing that handles all of that without configuration.
Only Caddy publishes ports. Postgres and the blob store are reachable from the
other containers and from nowhere else, so neither is exposed to the internet
even briefly. To get at them, go through Docker:
docker compose exec postgres psql -U postgres
A fifth container is available and off by default: background jobs can move
out of web into a jobs container of their own when they outgrow running as
threads inside it. Large private channels or heavy notification volume are the
signs. .env.example documents the switch, and it is two lines that must agree.
Your data is not in any of them. The database, the uploaded files and the TLS
certificates live in Docker volumes that outlive the containers, which is what
makes updating a matter of replacing containers and leaving everything else
alone.
Two names, not one
Mob is served from one name and its files from another — chat.example.com and
files.example.com, say. Both are ordinary A records pointing at the same
machine.
The second one exists because uploads and downloads go straight from the
browser to the blob store rather than through the app. That is what keeps a
200 MB video out of a Rails request, and it means the blob store is a public
address in its own right rather than an internal detail. It is the one part of
Mob's shape you have to know about before you begin, because both names need
certificates and a certificate is issued by connecting back to the name.
Add both records and let them resolve before the first start. A name that does
not resolve yet gets no certificate, and the retry is on the certificate
authority's schedule rather than yours.
Getting it running
There is an installer, and on a fresh machine it is the whole job:
curl -fsSL --connect-timeout 15 https://jojospacebear.com/install/install.sh | sh
It installs Docker if the machine has not got it, asks for the two names,
generates your four secrets, writes a .env, starts the stack, and then makes
your account and your first workspace. If you would rather read a script before
running it — a reasonable instinct — download it first:
curl -fsSL --connect-timeout 15 https://jojospacebear.com/install/install.sh -o install.sh
less install.sh && sh install.sh
It never overwrites an existing .env, so running it twice in the same
directory stops rather than replacing the secrets of a working installation.
For an unattended run, answer in advance and it will not prompt at all:
APP_HOST=chat.example.com MOB_STORAGE_PUBLIC_HOST=files.example.com \
MOB_ADMIN_NAME='Your Name' MOB_ADMIN_EMAIL=you@example.com \
MOB_ADMIN_PASSWORD='choose-something-long' MOB_WORKSPACE_NAME='Acme' \
sh install.sh
The last four are the only optional ones. Leave them out of an unattended run
and the stack still comes up — you are just left naming an owner in .env
yourself, which the installer tells you how to do at the end. That is the one
step it will skip rather than fail on: everything else has to happen for there
to be an installation at all, and this is the difference between an
installation and one you can sign in to.
Fresh, or a machine you are rebuilding
The installer asks this before it asks anything about an account, because the
two answers want different endings. A fresh installation gets an owner, a
workspace and somewhere to sign in. A machine you are rebuilding to restore a
backup onto gets none of that — the restore brings its own accounts — and
ends by pointing at restore.sh instead. Making an account on a machine that
is about to have its database replaced leaves you holding a password for a
login that no longer exists, which is a confusing way to start a recovery.
MOB_INSTALL_MODE is fresh or restore and answers it in advance. With no
terminal and nothing set, having all four answers above means fresh and
missing any of them means the accounts are left alone.
This is a different question from whether Mob has been installed here before,
which the installer settles by refusing to overwrite an existing .env. A
rebuild is a machine where there is no .env to find.
Docker, if the machine has not got it
A bare image is a complete starting point. Where Docker is missing, the
installer adds it on Debian, Ubuntu, Fedora and the RHEL family — Rocky,
Alma, CentOS Stream and RHEL itself — from Docker's own repository rather
than the distribution's, which tends to be older and, on Ubuntu, ships without
the Compose plugin this needs. It asks first where there is a terminal to ask
on, and it needs root or a user with sudo.
Set MOB_INSTALL_DOCKER to yes or no to answer that question in advance.
With no terminal at all — cloud-init, CI, a provisioning script — it installs
without asking, on the grounds that there is nobody there to ask and a machine
you told to set itself up is a machine you want set up.
Everywhere else it stops and tells you what to install. That includes macOS,
where Docker means Docker Desktop or OrbStack: an application, with a licence to
accept and a privileged helper to install, and not something an installer should
be agreeing to on your behalf. Install one, start it once, and run the script
again — the stack itself runs there perfectly well, though a laptop is a place
to try Mob rather than to serve it, since both names still need to resolve to
the machine before any certificate can be issued.
Nothing is uninstalled to make room. If the machine already has Docker from
somewhere else and it works, that is the Docker Mob uses.
By hand instead
The installer only does what you could do yourself, and it fetches three files
to do it: compose.yaml, Caddyfile and env.example, all from
https://jojospacebear.com/install/. Put them in a directory, then:
cp env.example .env
Now edit .env. Every value in it is explained where it sits. Six matter before
the first start: the two names above, and four secrets — any long random string
will do, and openssl rand -hex 32 is one way to get them. Everything else in
the file is optional and commented out.
docker compose up -d
docker compose logs -f web
The first start does considerably more than later ones. It pulls four images,
asks Let's Encrypt for a certificate for each of your two names, creates the
four databases and loads their schemas, and creates the bucket that uploads go
into. A couple of minutes is normal. When the log settles, open your first name
in a browser.
If it does not come up, the log you want is usually not the app's:
docker compose logs caddy is where a name that does not resolve says so.
The first account
There is no sign-up page and no seeded login — an installation that shipped with
a known account would be a back door. Instead, the account you sign in as is
named in your .env, and Mob makes it the first time anybody signs in. The
installer asks you for it at the same time as it asks for the two names, and
writes three lines:
MOB_ORGANIZATION_OWNERS=you@example.com
MOB_ORGANIZATION_OWNER_NAME=Your Name
MOB_ORGANIZATION_OWNER_PASSWORD=choose-something-long
That makes you an owner — the one authority no screen in Mob can hand out
or take away. Everything else is granted on a screen, and a grant needs
somebody to make it who cannot be appointed by the people they are appointing;
naming owners out here is what puts that floor in. Keep the list short, and
appoint administrators from the Accounts screen rather than lengthening it. See
Administering the organization.
You also get a first workspace with a #general in it that you are an admin
of. From there, invite people; that needs the console no more than this did.
Getting back in
Those three lines are also the way back in if the password is ever lost. No
administrator can reset an owner's password — that is the point of the tier —
so the environment has to be the door:
$EDITOR .env # change MOB_ORGANIZATION_OWNER_PASSWORD
docker compose restart web
The password is re-applied at every sign-in while the line is there. Delete it
once you are back in and the account keeps the password it has. The same goes
for the name: while MOB_ORGANIZATION_OWNER_NAME is set, its owner cannot
change it from their own profile, because a value typed there would be
overwritten at their next sign-in without explaining itself.
The name and password describe the first address listed. Any others get an
account with no password, and get in on a reset link from the first — one name
and one password is a floor, not a second roster.
If you skipped it
Answering rebuilding skips the workspace deliberately, and so does an
unattended run with none of the four answers set; an app that takes more than
three minutes to answer for the first time skips it by accident. In every case
the stack is up, and what is missing is a line in .env rather than a command
to paste. Add the three above, then:
docker compose restart web
Sign in, and make a workspace from Organization → Workspaces. Note that
creating one there does not put you in it: administering a workspace never
means joining it, so invite yourself like anybody else. The installer's version
is the exception, because at that point there is nobody else to invite.
Mob runs perfectly well with no owner named at all — you simply have nobody who
can appoint an administrator until you fill those lines in.
Four databases, not one
Mob uses Postgres for things other apps often reach for Redis for: caching,
background jobs and live updates. Each gets its own database alongside the main
one, so a fresh install has four — mob_production plus _cache, _queue and
_cable.
They are created for you. It is worth knowing they exist so that a backup covers
the right things, and so the count is not a surprise.
Updating
Installs made by the installer are pinned to the release they fetched:
MOB_VERSION in .env names it, and nothing moves until you raise it.
The installer leaves an updater beside the stack, and it is the short way:
cd mob
sh update.sh
It asks the registry which release is newest, tells you which one you are on,
points at the release notes, and does nothing else until you say yes. Then it
raises the pin, pulls, restarts, and waits to see the app answer — and if it
does not, it prints the log command and the two lines that put you back.
It replaces itself before each run. The copy on your machine was written by
whichever release installed it, and the one that knows how to move you onto
release N is the one shipped with N, so it fetches a fresh copy first and hands
over. A machine that cannot reach the site carries on with the copy it has and
says so.
It stops rather than acting when there is no terminal to ask on. If you have
decided in advance — in a cron job, say — MOB_UPDATE_YES=1 sh update.sh.
By hand
The script does three things, and there is nothing to stop you doing them
yourself. Edit MOB_VERSION in .env, then:
docker compose pull
docker compose up -d
If you never ran the installer, or deleted the script, fetch it again:
curl -fsSL --connect-timeout 15 https://jojospacebear.com/install/update.sh -o update.sh
Which release to raise it to
Organization → Updates answers this, once you have asked it to.
Mob does not check for updates unless you switch it on, and until you do it
contacts nothing at all. That is the default because a self-hosted installation
is one you chose in order to know what leaves your machine, and an installation
that has never been asked has consented to nothing. The cost falls on you and is
worth stating plainly: nothing will tell you that a release exists. Either
switch checking on, or read the release notes when it suits you.
Switched on, it asks once a day which release is newest, shows the answer beside
the release you are running, and messages organization admins the first time a new one
appears — once per release, not once a day. The request goes to one place, the
registry this installation already pulls its images from, and carries no
identifier, no counts and nothing about your workspaces or the people in them.
Switching it back off stops the asking; what it already learned stays on the
screen.
The page has no button that updates anything, deliberately. Raising the pin is
a decision taken on the machine, which is the whole reason the pin exists.
Without the screen
The release running is also at the foot of the sidebar under Sign out,
small, beside the revision it was built from. The newest published one is a
label on the image, and can be read straight out of the registry without
pulling anything and without touching what is running:
docker buildx imagetools inspect ghcr.io/wibbly/mob:latest --format '{{ range $p, $i := .Image }}{{ println (index $i.Config.Labels "org.opencontainers.image.version") }}{{ end }}' | head -1
The head is because one release is built for two architectures and the label
is read from each, so the number arrives twice. If that machine has no buildx
(the installer adds it, but a Docker that came from somewhere else may not have
it), pull the tag and ask the image instead. Pulling latest does not move a
pinned installation: it puts an image on the disk and changes no container.
docker pull ghcr.io/wibbly/mob:latest
docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' ghcr.io/wibbly/mob:latest
Both answer what the newest release is, and neither answers whether to take
it — Releases is where that is written down. Nothing is
skipped by being several behind: migrations run forward through every release
between yours and the one you raise the pin to, in the order they were written.
So the caution is the ordinary one — take a backup first, and raise it at a
time when somebody is around to look at the result.
Going backwards is the one that bites
Nothing refuses a lower MOB_VERSION, and migrations do not run backwards. An
installation started on an older image than the one that migrated its database
is old code against a newer schema: it boots, finds nothing pending, and simply
behaves oddly around whatever changed. Mob records which release last migrated
it and says so on the Updates screen if the two disagree — but the fix is
always to raise the pin back, not to carry on.
That replaces the containers and leaves the volumes, so nothing you have is
touched. Migrations run as the new web boots, which means there is no separate
step and no window where the code and the schema disagree. Take a backup first —
Backups — and read that page before you have users rather than
after.
Blank MOB_VERSION and docker compose pull follows the newest release
instead — which is what installs made before the pin existed already do.
Pinning is theirs to add: one line in .env, set to the release currently
running.
docker compose down stops everything and keeps your data. docker compose down -v deletes the volumes, which is the database, every uploaded file and
your certificates. There is no undo.
Next
- Configuration — the environment variables, and which ones production insists on
- Blob storage — where uploaded files go, and the two addresses
- Backups — read this one before you have users
- Deploying — the Kamal path, used for the hosted instances, and not something a Compose install needs