Deploying by hand
The advanced route: Docker Compose, a `.env` you write, and a reverse proxy you run. Most boards should deploy with Coolify instead.
Advanced. The Quickstart deploys a board with Coolify — since 0.17.0, a board of your own rather than this repository's own image, which is what lets the marketplace actually install into it — and is the route most boards should take: same four containers, same environment contract either way, and it issues the certificate and generates the secrets for you. This page deploys this repository's own stock board instead, by hand — same shape, no panel — for whoever minds a community's machines, not for the organisers; it assumes a terminal, a text editor and no fear of either. Want your own board's shape without the panel? Skip to Custom boards.
This page is the same board without the panel: the compose file, a .env
you write, and a reverse proxy you already run. Take it if:
- you already run a proxy (nginx, Traefik, Caddy) and would rather add one vhost than a second thing that wants ports 80 and 443;
- you want no extra moving parts — Coolify is a daemon, a database and a proxy of its own, a fair price for what it does and not free;
- the machine is too small for it — Coolify wants ~2 GB to itself;
- you are deploying into something else — an existing Swarm, a Nomad job, a CI pipeline that already builds images.
What you give up: certificates, secret generation, the redeploy button, and the scheduled database backup. All four become yours, and the first is the one people underestimate.
What you need
| A server | Your own, anywhere. 2 GB RAM, 2 vCPU, 20 GB disk. Ubuntu 24.04 LTS below; any distro Docker runs on is fine. |
| A domain | With an A record pointing at the server before you start — the certificate step needs it resolving. |
| Half an hour | And a terminal. |
1 GB works for a quiet board but is tight during the build. If that is what you have, use the published image instead of building.
1. Prepare the server
SSH in as root, make a user, and give it Docker:
adduser meith
usermod -aG sudo meithInstall Docker from Docker's own repository rather than the distro's — the
packaged version is usually old enough to be missing docker compose:
curl -fsSL https://get.docker.com | sh
usermod -aG docker meithThen close the machine off. Everything a visitor needs is 80 and 443; the board itself is never exposed directly:
ufw default deny incoming
ufw allow OpenSSH
ufw allow 80,443/tcp
ufw enableLog out and back in as meith — group membership only takes effect on a
new session, and docker ps failing with a permissions error at this
point is almost always that.
2. Get the board
git clone https://github.com/meith-dev/meith.git
cd meith
git checkout "$(git describe --tags --abbrev=0)" # the newest releaseA clone rather than a release tarball, because upgrading is a fetch and a
checkout of the next tag, and because
docker/compose.yml is a file you are meant to
read and edit. The checkout matters: main is development and makes no
promises between releases; a release tag is what the published images are
built from and what the release process stands behind.
3. Write the environment
Everything from here on happens in docker/ — the compose file lives
there and reads .env from beside it. Nothing in that file belongs in
git; .gitignore already covers it.
cd docker
cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 32)
AUTH_SECRET=$(openssl rand -base64 32)
TICK_SECRET=$(openssl rand -base64 32)
APP_URL=https://board.example
EOF
chmod 600 .envEdit the last line to your real domain — it is the only line you type.
| Line | What it is |
|---|---|
POSTGRES_PASSWORD |
The database's own password. Generated, never typed — and hex, see the note below. The compose file has a well-known default, so set your own. |
AUTH_SECRET |
Signs unsubscribe links in outgoing mail and seals two-factor secrets. Sessions are not derived from it — they are random tokens stored hashed. There is deliberately no default: a shipped one is a link every reader of the source can forge. Compose refuses to start without it. |
TICK_SECRET |
Guards /api/system/tick, which is publicly routable. Presented as an Authorization: Bearer header, never in the query string — see driving the tick over HTTP. Compose refuses to start without it too. |
PORT |
Optional. The Compose default is 127.0.0.1:3000, so the TLS proxy is the only route in. Setting this to 3000 deliberately binds every interface and publishes a plaintext route alongside HTTPS; Docker writes its own iptables rules, so ufw may not stop it. |
APP_URL |
The board's public origin. The compose file otherwise defaults it to http://localhost:3000, which is suitable only for local access; every link in every password-reset and confirmation e-mail is built from it. It must be your real origin, not a placeholder. |
Rerunning that heredoc rewrites every value. If the board is already
installed, changing POSTGRES_PASSWORD locks it out of its own database —
Postgres keeps the password from when the volume was created.
Rotating AUTH_SECRET later signs nobody out — sessions do not depend on
it. What it breaks is the unsubscribe link in every message already sent
(they answer with a polite failure), and every member's two-factor
enrolment, which has to be set up again. Safe if you think it leaked; not
free.
.env.example at the repository root documents every
other variable, including the MAIL_* set — optional, overriding the
board's own mail settings when present, and worth setting here if you
would rather this deployment were configured entirely from files.
That includes METRICS_ENABLED/METRICS_TOKEN and OTEL_ENABLED/
OTEL_EXPORTER_OTLP_ENDPOINT — off by default, and not needed for a working
board. See Monitoring & alerting once you want a
Prometheus scrape target or distributed tracing.
4. Start it
docker compose up -d --buildThe first build takes five to ten minutes. Four containers come up in order:
| Container | What it does |
|---|---|
postgres |
The database. A named volume, so recreating the container keeps the data. |
migrate |
Applies the schema and exits 0. web and worker wait for it, so the code never runs against a schema behind it. |
web |
Next.js, on 127.0.0.1:3000. |
worker |
The background tick, in-process, on its own one-minute loop. |
Check all four:
docker compose ps
docker compose logs -f workermigrate showing Exited (0) is correct — it is what the other two
waited for. The worker should log worker started once, and then not
much; if that line repeats every few seconds, the container is
crash-looping and the log above it says why.
One web container is the topology this walkthrough sets up, and the
right one to start with. When the board outgrows it, Scaling
out is the path — the compose file already carries the
redis profile (running Valkey) and the variables it needs, so scaling
later means changing configuration, not redoing this guide.
Each long-running container carries a memory and CPU ceiling sized for a
small VPS — a gigabyte and two cores for web, a gigabyte and a core for
postgres, 768 MB for worker — and rotates its own logs at three files
of 10 MB each. The ceilings are limits, not reservations: a quiet board
holds nothing back, and their sum deliberately exceeds the 2 GB minimum
above. They are not there to fit inside the machine but to contain a
failure within one container — a leak in web gets web killed and
restarted, rather than inviting the host's OOM killer to pick its own
victim, which can be sshd or Docker itself. A bigger machine raises
them from the same .env the
secrets live in — WEB_MEM_LIMIT, WEB_CPUS, POSTGRES_MEM_LIMIT,
POSTGRES_CPUS, WORKER_MEM_LIMIT, WORKER_CPUS, plus REDIS_MEM_LIMIT
and REDIS_CPUS for the scaling profile — never by editing the compose
file, so an upgrade's git checkout has nothing of yours to collide with.
Anything the variables do not reach goes in a compose.override.yml
beside the compose file: compose merges it automatically, and it is yours,
untracked, upgrade after upgrade. The log cap is what keeps a
crash-looping container from writing the disk full: restart: unless-stopped restarts it forever, and every restart logs.
5. Put a proxy in front
Nothing in the compose file terminates TLS. Caddy, because it gets a certificate and renews it without being asked. On the host, not in the compose file:
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf https://dl.cloudsmith.io/public/caddy/stable/gpg.key \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy/etc/caddy/Caddyfile, in its entirety:
board.example {
reverse_proxy 127.0.0.1:3000
request_body {
max_size 25MB
}
}sudo systemctl reload caddymax_size has to be at least your largest allowed attachment, or the
upload fails at the proxy with a 413 the board never sees and cannot
explain.
Prefer nginx? The equivalent is a proxy_pass to 127.0.0.1:3000 with
client_max_body_size 25m, proxy_set_header X-Forwarded-Proto https,
and certbot for the certificate. Nothing about the board cares which you
pick.
Count your proxies
The board only sees a visitor's address because the proxy forwards it, and it has to be told how many proxies did the forwarding. One — the setup above — is the default, so there is nothing to set for this Caddyfile.
Put a CDN or a second load balancer in front of Caddy and the chain grows
by one: set TRUSTED_PROXY_HOPS=2. Whatever the number, it must match
reality — too high and a visitor can forge their address by sending a
forwarding header of their own, walking past ADMIN_IP_ALLOWLIST and the
login lockout. If you expose the board's port directly with nothing in
front — do not, but if you do — set TRUSTED_PROXY_HOPS=0 so the header
is ignored outright.
Whatever you put in front must pass the board's
Content-Security-Policy response header through untouched: it carries a
per-request nonce, and a proxy that caches or rewrites it serves pages
whose scripts the browser refuses.
6. Install it
Open https://board.example/install — your domain, over the proxy you
just set up, not 127.0.0.1:3000.
The form is three numbered sections: Your board (its name), Your
account (username, e-mail, password), and Sending mail (optional
here, painful later). The board's address is not asked for — APP_URL
from your .env supplies it, and the preflight report names the value it
is using. Check that line: it is the origin every password-reset and
confirmation link is built from, and a wrong APP_URL is the one mistake
this route makes easy.
Your username is the name you post under, not a role, and the obvious names are reserved so no account can impersonate the board — the form lists them under the box.
Fill in mail here too. It is a list of providers rather than a page of server details — pick the one you have and the host, port and TLS mode come with it — and a test message goes to your address before the first migration, installing nothing if it fails.
Everything else about the installer — the preflight report, the five steps, the sealing that cannot be undone — is the same on both routes and written once:
- Quickstart § Run the installer
- Quickstart § Mail — the answer sheet for
the provider list.
/admin/settings?group=mailchanges it afterwards with no redeploy; theMAIL_*variables in the.envbeside this stack override both. - Operations — the operator handbook: backups, the CLI, permissions, spam, and the failures that actually happen.
Upgrading
cd ~/meith/docker
git fetch --tags
git checkout v0.6.0 # the release you are moving to
docker compose up -d --buildmigrate runs first and the others wait for it, so the schema is never
behind the code. Take a backup first — migrations are forward-only
and recovery is by restore; see
backup and restore.
That applies core migrations only. Plugin migrations run through
community upgrade — see
the operator CLI for how to run it on
this deployment, and Upgrading a board for how far you
can jump in one go.
Building somewhere else
On a 1 GB server the Next build can run out of memory. The shortest fix
is to not build at all: every release publishes this project's own image
— multi-arch, boot-tested in every role — so replace each build: block
in the compose file with image: ghcr.io/meith-dev/meith:0.6.0 and
everything else is unchanged.
Pin the exact version: a floating tag turns the next incidental
docker compose pull into an unplanned upgrade. Upgrading is then
editing the pin — the same deliberate act as checking out the next tag.
Build on your own machine or in CI and push to your own registry only
when you have patched the source; that is what this route is for.
The image takes COMMUNITY_ROLE — web, worker or migrate — so one
image is all three services. That is what makes the roles impossible to
drift apart, and why there is no second Dockerfile.
Custom boards
Everything above deploys this board — the stock one, whatever plugins
and themes apps/community's own community.config.ts compiles in.
Installing a third-party plugin does not mean forking this repository:
curl -fsSL https://www.meith.dev/create-board.sh | bash -s -- <name>scaffolds a small workspace of its own — package.json,
community.config.ts, board.plugins.json — that depends on the
published @meith/web and @meith/cli packages instead, and comes with
its own deploy kit already written: Dockerfile, docker-compose.yml and
.github/workflows/build.yml, plus a git repository already initialized
and staged. npx create-meith <name> does the identical thing for anyone
who already has Node.js and would rather use it, and clicking Use this
template on meith-dev/template
does it with no local tooling at all — GitHub creates the repository and
its first commit directly. See Consuming the board
from a workspace
for the mechanism (forum-web/community — the same bins this
repository's own image is built through, see the stock
board) and the plugin
API for installing a plugin once the workspace exists.
The "server pulls an image; something else builds it" promise this
document stands behind carries over to a custom board, with one
difference: instead of a release publishing ghcr.io/meith-dev/meith, an
operator's own GitHub Actions build their own board and push it to their
own registry. Three steps:
- Push the scaffolded repository to GitHub.
create-meithalready initialized it and staged every file, so this is a commit and a push, not agit init— see Development § Consuming the board from a workspace..github/workflows/build.ymlbuildsDockerfileon every push tomainand pushes the image toghcr.io/<you>/<board>— the automaticGITHUB_TOKENevery workflow run already carries is enough; there is no secret to add. The run's own Summary tab, once it finishes, prints the exact image for step 2 and a direct link to the one-time step of making the resulting package public — it starts private, and Coolify's pull fails with an authentication error no operator can act on until that is done. - Point Coolify at the scaffolded repository — a Docker Compose
resource, that repository as its source, the same mechanics the
Quickstart walks through
step by step for the same scaffold.
docker-compose.ymlis named for Coolify's own default, so there is no path to type; it carries the same Coolify magic variablesdocker/compose.coolify.ymldoes forAUTH_SECRET,TICK_SECRETand the database password. The one thing it cannot generate is the image step 1's Summary just printed, so the operator setsMEITH_IMAGEonce, in the resource's own environment — the compose file refuses to start without it, with a message saying so. - Redeploy, then run
/installon the operator's own domain — identical to Quickstart § Run the installer. Every push tomainafter this rebuilds the image; Coolify's own Redeploy is what actually pulls it.
No Docker Hub, no paid CI, whatever the board's size: GitHub Actions' free tier and GHCR are the whole build side, exactly as they are for the official image.
The scaffolded Dockerfile's FROM line pins
ghcr.io/meith-dev/meith-base:X.Y.Z — a published, framework-only image
(the @meith/web, @meith/cli and @meith/theme-default dependency
closure, version-locked, no board config and no secret) that this
project's release pipeline rebuilds alongside ghcr.io/meith-dev/meith
itself on every release (docker/Dockerfile.base). A scaffolded board's
own Dockerfile only ever installs its own delta on top of that already-warm
layer — a newly added plugin's own dependency, typically nothing more —
which is what keeps "add a plugin, redeploy" a build of minutes rather than
a cold toolchain build every time. The base image's tag is not written into
the scaffolded Dockerfile at all: FROM takes it as a build argument,
and .github/workflows/build.yml reads the value straight out of
package.json's own @meith/web dependency on every build — so an
upgrade is that one line in package.json, never a second pin to remember
in Dockerfile too; create-meith's own generated README documents the
exact commands.
The scaffolded Dockerfile runs its build as RUN DATA_SOURCE=fixture npx forum-web build, scoping fixture mode to that one command rather than
declaring it ENV — this Dockerfile has no later, separate runtime stage to
reset a build-only ENV in, so an ENV DATA_SOURCE=fixture would leak into
every container started from the image, silently forcing fixture mode (and
the in-memory queue driver it implies) in production regardless of the
DATABASE_URL an operator supplies at docker run time.
@meith/worker is not published, so a scaffolded board's image carries no
compiled worker binary — its docker-compose.yml's own worker service drives
the tick the way below
describes instead: a small loop against /api/system/tick, needing nothing
this image does not already expose.
Building nowhere but a laptop is still available, exactly like "Building
somewhere else" above, for an operator who would
rather not use GitHub Actions for the build at all — MEITH_VERSION has no
default, so a bare docker build -t <board> . fails; the value is the
scaffolded repository's own @meith/web dependency in package.json, the
same one .github/workflows/build.yml reads:
docker build --build-arg MEITH_VERSION=$(node -p "require('./package.json').dependencies['@meith/web']") -t <board> .Running the tick without a second set of credentials
The worker service holds database credentials, which some operators
would rather only the web server did. The compose file ships an
alternative behind a profile: a small container that calls
/api/system/tick over HTTP once a minute, presenting TICK_SECRET in
an Authorization: Bearer header:
docker compose --profile curl-tick up -dEnable that or worker, never both. Running both is harmless — a
task claims its work in the database, so concurrent ticks are safe — but
it is two things doing one job.
When it goes wrong
| What you see | What it is |
|---|---|
AUTH_SECRET must be set, before any container starts |
Compose itself refusing to interpolate: .env is not beside the compose file, or you ran docker compose from another directory. |
TypeError: Invalid URL from migrate |
A / or + in POSTGRES_PASSWORD. Generate it with openssl rand -hex 32. |
migrate exits non-zero |
Read its log. A failed migration stops the stack on purpose rather than serving against a half-applied schema. |
Worker logs worker started every few seconds |
It is crash-looping. docker compose logs worker shows the throw above each restart. |
| 502 from the proxy | The web container is not up, or PORT is not 127.0.0.1:3000. curl -I http://127.0.0.1:3000/api/health on the host settles which. |
| 413 on an upload | The proxy's body limit, not the board's. See max_size above. |
| Uploads vanish after a redeploy | The uploads volume is not mounted. docker volume ls and docker compose config will show it. |
The board is reachable on :3000 as well as :443 |
PORT is 3000 rather than 127.0.0.1:3000. Docker writes its own iptables rules, so ufw will not have stopped it. |
| Password reset says "check your inbox" and nothing arrives | Mail is not configured, or the provider is refusing it. /admin/settings?group=mail → Send a test message to me answers which, and prints the provider's own refusal. |
| Mail arrives, but its links point at the wrong host | APP_URL in .env is wrong — fix it there and redeploy. |
Operations § Troubleshooting covers the failures that are about the board rather than the deployment.
What you are taking on
Worth being plain about, because this is the route with no panel behind it:
- Backups are yours. Nobody else is taking one.
community backupbundles the database and the uploads; the cron and the offsite copy are still yours to build. See backup and restore, and the disaster-recovery runbook for the day they are all you have. - Certificates are yours. Caddy makes it a solved problem, but it is a problem you now own.
- Security updates are yours.
unattended-upgradesfor the host; a checkout of the next release and a rebuild for the board. - Uptime is yours.
restart: unless-stoppedcovers a crash and a reboot; it does not cover a disk filling up. The compose file caps what each container may log, so a crash-loop cannot fill the disk by itself — but the database and the uploads still grow, and watching the disk is still yours.
In exchange: no platform limits, no per-seat pricing, no vendor reading
your members' posts, and a board you can move to another machine with a
pg_dump and a tar.
Why a server, and not functions
You can run this board on functions, and Running on Vercel is that route written out: the driver set, the build command that carries the migration, the cron job that stands in for the worker, and how to leave again.
A server is still the recommended default, and the reason is that
everything this page gives you for free becomes configuration and a
second bill there. A process that outlives a request is what a worker
is; without one, the tick is an HTTP endpoint somebody else's scheduler
has to call, at a cadence their plan decides. A disk that survives a
restart becomes an object store. A shared cache becomes a managed Redis.
The migration stops being a one-shot job beside the board and becomes
part of the build, which trades the deploy window for a different one
rather than closing it. None of that is unworkable — it is documented
because it works — but it is four vendors and a longer list of things to
get right, in exchange for not owning a machine. One machine, one
database, one pg_dump that is the whole board is the simpler answer,
and it is the one most boards should take.