Can I self-host Trello?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called WeKan. It takes one prompt, a 2048 MB VPS, and about 90 minutes. That is $30 a month you stop paying Trello — $360 a year on the Standard plan, 5 seats assumed.

Why people pay for Trello

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Trello sells a board anyone can be handed. A person who has never used it drags one card and understands the whole system, and that is why it spreads sideways through a company without anybody deciding to adopt it. What the paid tiers actually add is the machinery a board grows into once it is load-bearing: unlimited boards, the extra views, Butler automation, guest and permission controls, and the Atlassian account plumbing that ties it to the rest of the work.

Trello plans and list prices
PlanList priceWhat it buys
FreefreeFree for up to 10 collaborators per Workspace, with unlimited cards and a limit on boards per Workspace.
Standardthe plan this page prices against$6/mo per seatBilled monthly. $5 per user a month if billed annually, which is how the pricing page leads.
Premium$12.50/mo per seatBilled monthly. $10 per user a month if billed annually.
Enterprise$17.50/mo per seatBilled annually only: $17.50 per user a month, which the page states as $210.00 a year per user. That figure is the rate at the seat band the page shows by default, and the per-user price falls as the seat count rises.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: high

Replaced by WeKan

One project, named before the prompt, so you know what you are about to install.

Trello-shaped boards with swimlanes, checklists and attachments, on a hostname you own and a database you can dump.

The closest thing here to the Trello object model, and the only one that ships the pieces people quietly depend on: swimlanes, checklists inside cards, labels, due dates, per-board members and file attachments. It also has the migration path, an importer that takes a board's Trello JSON export and maps its members onto WeKan accounts, though upstream's own page for it currently lists an open fix, so treat the import as a first attempt rather than a guarantee. What it costs you is a MongoDB to operate and a release cadence that runs to several tagged versions a day, and Butler automation, Power-Ups and the calendar and timeline views do not come across at all.

The swap

You're paying

Trello

$30/mo · $360/yr

is replaced by

You'd run

WeKan

ONE EVENING · ~90 min to running · 2048 MB RAM

Trello Standard · 5 seats assumed · vendor list price · checked 2026-08-06 · source

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
10 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~90 min1–3 hours, through the first backup

The prompt

Two paths to the same WeKan: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

327 lines · 14,965 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install WeKan v10.71 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server, and the same hostname becomes `ROOT_URL` inside
the container, which step 10 is about.

WeKan plus MongoDB needs 2048 MB of RAM available and 10 GB free on /srv. Both images publish
amd64 and arm64. MongoDB 7 also needs the AVX instruction set on x86, which is why upstream
documents a qemu detour for old hardware. Measure all five:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
grep -c -w avx /proc/cpuinfo || true
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If the architecture is `amd64` and the AVX count is `0`, stop as well:
mongod exits during start-up on that CPU and no environment variable fixes it. If `dig +short`
prints nothing, print that and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/wekan /srv/wekan/backups
ls -la /srv/wekan
```

Assert: `ls -la` shows `backups` owned by the login user. There is no `data` directory here on
purpose: MongoDB chowns /data/db to its own uid and the WeKan image chowns /data to a system
user made at image build time, so both live in named volumes that step 8 backs up.

## 3. Secrets

This install generates no secrets, so there is nothing here to keep out of your summary. WeKan
has no admin token, and its only credential is the first account, made in a browser in step 7.
MongoDB runs with no password: it publishes no host port, only the WeKan container reaches it,
and access control on a replica set would mean a shared key file on a uid-owned mount.

One value still has to arrive from outside these files, because it carries the hostname:

```bash
umask 077
cat > /srv/wekan/.env <<'EOF'
ROOT_URL=https://<DOMAIN>
EOF
chmod 600 /srv/wekan/.env
umask 022
cat /srv/wekan/.env
```

Assert: mode `-rw-------`, and one line reading `ROOT_URL=https://` followed by the real
hostname, with no trailing slash and no quotes, which is the form upstream documents.

## 4. compose.yml

```bash
cat > /srv/wekan/compose.yml <<'EOF'
# WeKan · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   mongodb compose .... https://github.com/wekan/wekan/blob/v10.71/docker-compose-mongodb-v7.yml
#   root url and proxy . https://github.com/wekan/wekan/blob/v10.71/docs/Webserver/Settings.md
#   oplog reactivity ... https://github.com/wekan/wekan/blob/v10.71/docs/Databases/MongoDB/Oplog-Configuration.md
#   image and port ..... https://github.com/wekan/wekan/blob/v10.71/Dockerfile
#
# Two services: WeKan and the MongoDB 7 that holds every board. Both data
# directories are named volumes, because MongoDB chowns /data/db to its own
# uid and the WeKan image chowns /data to a system user made at image build
# time. ROOT_URL arrives from /srv/wekan/.env because it carries the hostname,
# and a Meteor application opens its live data socket at whatever ROOT_URL
# says. Digests read on 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wekandb:
    image: mongo:7.0.39@sha256:35a5926f71f8b6cb19206bee928c5a85f241a8be99f20c81abe35ae78a73415d
    container_name: wekan-db
    restart: unless-stopped
    # A one-member replica set, which is what makes the oplog exist. Meteor
    # tails it instead of re-reading whole collections on a timer: upstream
    # measures 50 ms rather than 2000 ms before another person's card move
    # lands on screen. Step 7 initiates the set once.
    command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--quiet"]
    volumes:
      - wekan-db:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "try { quit(db.hello().isWritablePrimary ? 0 : 1) } catch (e) { quit(1) }"]
      interval: 10s
      timeout: 10s
      retries: 30
      start_period: 30s
    # No `ports:` at all: 27017 is reachable only from the other container.

  wekan:
    image: wekanteam/wekan:v10.71@sha256:5ccfd900c9b68fd9ebd9eb194d286119fdee000ba1e907df583ce942bad54fdc
    container_name: wekan-app
    restart: unless-stopped
    env_file: /srv/wekan/.env
    environment:
      # The image can also start a FerretDB it carries inside itself; naming
      # the backend takes that decision away from the entrypoint.
      WEKAN_DB: mongodb
      MONGO_URL: mongodb://wekandb:27017/wekan
      MONGO_OPLOG_URL: mongodb://wekandb:27017/local?replicaSet=rs0
      METEOR_REACTIVITY_ORDER: changeStreams,oplog,polling
      # SockJS rather than uws, upstream's own default, and it puts a plain
      # http endpoint at /sockjs/info that step 7 checks.
      DDP_TRANSPORT: sockjs
      # Attachments and avatars are files under this path, not database rows.
      WRITABLE_PATH: /data
      WITH_API: "true"
    volumes:
      - wekan-files:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8104.
      - "127.0.0.1:8104:8080"
    depends_on:
      wekandb:
        condition: service_healthy

volumes:
  wekan-db:
  wekan-files:
EOF
cd /srv/wekan && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. WeKan serves on 8080 inside its container and 8104 is bound to
127.0.0.1 on the host, so Caddy is the only route in.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-wekan
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# WeKan · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/wekan/wekan/blob/v10.71/docs/Webserver/Caddy.md and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also ROOT_URL in .env and the two have to say exactly the same thing: WeKan
# is a Meteor application, so the browser opens its live data socket at
# whatever ROOT_URL claims, and a mismatch gives a sign-in screen that works
# above boards that never finish loading.

<DOMAIN> {
	# Nothing here is meant to be embedded in another site, and board and
	# card names travel inside these URLs, so the referrer is trimmed.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8104 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. The live data socket
	# rides the same route: Caddy performs the WebSocket upgrade with no extra
	# directive, which is why there is no websocket stanza here.
	reverse_proxy 127.0.0.1:8104
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-wekan, reload, and report what it objected to. Caddy gets the
certificate on the first request and renews it alone, and performs the WebSocket upgrade the
live data socket needs with no extra directive.

## 6. Firewall

Two ports open, both Caddy's, and idempotent on a box Prompt Zero configured:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8104 stays closed because it is bound to 127.0.0.1, and 27017 because compose
never publishes it. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule for 8104 or 27017.

## 7. Start and verify

MongoDB stays unhealthy until its one-member replica set is initiated, which is what creates
the oplog Meteor tails.

```bash
cd /srv/wekan
docker compose pull
docker compose up -d wekandb
for i in $(seq 1 20); do docker compose exec -T wekandb mongosh --quiet --eval 'db.adminCommand({ping:1}).ok' >/dev/null 2>&1 && break; sleep 5; done
docker compose exec -T wekandb mongosh --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "wekandb:27017"}]}).ok }'
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/sign-in); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/sign-in | grep -o '<title>Wekan</title>'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/sockjs/info
docker compose exec -T wekan printenv ROOT_URL
```

Assert all four, and print what you received for each. The mongosh line prints `1`. The loop
ends printing `200`. The grep prints `<title>Wekan</title>`, which WeKan writes into every page
it serves, so it proves the application answered. `/sockjs/info` prints `200`, the live data
socket's own endpoint reaching the container. `printenv` prints `https://` and the hostname,
with no trailing slash. If any of the four misses, stop, run
`docker compose logs --tail 40 wekan` and `docker compose logs --tail 20 wekandb`, and name the
likely cause: a database that never reports healthy points at the replica-set line above, a
`502` at step 5, a `printenv` that disagrees with the hostname at step 3. A running container is
not success.

The first screen at https://<DOMAIN>/sign-in is a form headed `Sign In`, with username and
password fields and a link to register. Upstream states the first registered user becomes the
administrator, which is why the next step is a hard stop.

STOP: tell the user to open https://<DOMAIN>/sign-up now, register their username, email address
and password, and confirm once they are signed in. Do not continue until they confirm. Tell them
an `Internal Server Error` there is upstream's documented answer when no mail server is set: the
account is made anyway, so sign in and check.

Registration is open to anyone who reaches the hostname. Close it now:

```bash
cd /srv/wekan
printf 'db.settings.updateOne({}, {$set: {disableRegistration: true, modifiedAt: new Date()}});\n' | docker compose exec -T wekandb mongosh --quiet wekan
docker compose restart wekan
sleep 30
printf 'print(db.settings.findOne({}).disableRegistration);\n' | docker compose exec -T wekandb mongosh --quiet wekan
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/sign-in
```

Assert: the second mongosh line prints `true` and the curl prints `200`. That setting is
enforced on the server rather than in the page: with it on, WeKan refuses an account that
arrives without an invitation code. Then have the user reload the sign-in page and confirm the
register link is gone. Both asserts and that confirmation land before you report success.

## 8. First backup and restore

Three artifacts: boards, attachments and configuration live in three places.

```bash
cd /srv/wekan
docker compose exec -T wekandb mongodump --quiet --archive --gzip --db=wekan > /srv/wekan/backups/wekan-db-$(date +%F).archive.gz
docker compose exec -T wekan tar -C /data -czf - . > /srv/wekan/backups/wekan-files-$(date +%F).tar.gz
sudo tar -czf /srv/wekan/backups/wekan-config-$(date +%F).tar.gz -C /srv/wekan compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/wekan/backups/
```

Assert: all three exist and none is empty. Print all three sizes. Nothing goes offline:
`mongodump` reads a running database consistently. A backup on the same disk as the data is not
a backup, so run this one from the user's machine:

```bash
mkdir -p ~/backups/wekan
scp vps:/srv/wekan/backups/* ~/backups/wekan/
```

To restore: `docker compose up -d wekandb`, wait for it to answer ping, re-run step 7's
replica-set line (on a fresh volume the container reports healthy only after it), feed the
`.archive.gz` into `docker compose exec -T wekandb mongorestore --archive --gzip --drop`, untar
the config archive into /srv/wekan, `docker compose up -d`, then feed the files tarball into
`docker compose exec -T wekan tar -C /data -xzf -`. Tell the user the stakes: every card,
comment and attachment is in that dump and that tarball, and a board nobody copied off the box
dies with the disk.

## 9. Updating later

New versions are listed at https://github.com/wekan/wekan/releases. WeKan often ships several
releases in one day, so read that page as a list, not a queue. Take all three backups first,
then edit the image line in /srv/wekan/compose.yml to the new tag and digest:

```bash
cd /srv/wekan
docker compose pull
docker compose up -d
docker compose logs --tail 30 wekan
```

WeKan runs its own schema migrations on the way up, so watch that log until it settles, then
re-run step 7's four asserts before calling the update done. Leave the MongoDB line alone: a
database major version is a separate migration with its own dump and restore.

## 10. What will probably go wrong

`ROOT_URL`. I left it at the `http://localhost` upstream's compose file ships with, the sign-in
page rendered over https perfectly, and I believed the install had worked. Then every board hung
on a loading spinner and neither container log said why. The page comes from Caddy,
but a Meteor application tells the browser to open its live data socket at whatever `ROOT_URL`
claims, and mine sent the browser back to its own machine. If a board never finishes loading,
run `docker compose exec -T wekan printenv ROOT_URL` first. It has to be `https://` and the
hostname, no trailing slash, no port.

## 11. Out of scope

- Do not configure `MAIL_URL` or `MAIL_FROM`. Upstream states in writing that a working email
  server is not required, and outbound mail from a fresh VPS is a fight for a different day.
- Do not enable OAuth2, OIDC, LDAP, SAML or CAS. Each is an account somewhere else and a second
  failure mode, and this install already has a working sign-in.
- Do not switch the database to FerretDB. The image carries one, and that is a different install
  with a different backup story.
- Do not set the `S3` variable and do not publish 27017. Attachments belong in the volume this
  prompt backs up, the database on the network where nothing else reaches it.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing WeKan v10.71 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the
box.

Read this before step 1. That hostname becomes `ROOT_URL` inside the container, and WeKan is a
Meteor application, which means the browser opens its live data socket at whatever `ROOT_URL`
says rather than at the address the page came from. Get it wrong and you get a sign-in page that
looks perfect above boards that never finish loading. Step 10 is about nothing else.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
grep -c -w avx /proc/cpuinfo || true
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, a
non-zero AVX count on amd64, and your server's IP on the last line.

If you do not: an AVX count of `0` on an `amd64` box is a stop, not a warning. MongoDB 7 needs
that instruction set and mongod exits during start-up without it, which upstream documents as
the reason old hardware needs a qemu detour. An empty last line means the A record does not
exist yet: add it, wait a minute, and run `dig +short <DOMAIN>` again, because Caddy cannot get
a certificate for a hostname that does not resolve and failed attempts count against a rate
limit you cannot see. Under 2048 MB free RAM is also a stop: MongoDB and a Node application both
want memory, and the OOM killer arrives during your first import.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/wekan /srv/wekan/backups
ls -la /srv/wekan
```

You should see: `backups`, owned by you, and nothing else.

If you do not: there is deliberately no `data` directory. MongoDB chowns /data/db to its own uid
and the WeKan image chowns /data to a system user made when the image was built, so both live in
named volumes that Docker owns, and step 8 takes the backups through the containers rather than
off the disk.

## 3. Secrets

This install generates none. WeKan has no admin token, and its only credential is the first
account, which you create in a browser in step 7. MongoDB runs with no password: it publishes no
host port, only the WeKan container can reach it, and turning on access control for a replica
set means a shared key file on a uid-owned mount for a database nothing else can talk to.

One value still has to reach the container, because it carries your hostname. Replace `<DOMAIN>`
on the first line before you paste:

```bash
umask 077
cat > /srv/wekan/.env <<'EOF'
ROOT_URL=https://<DOMAIN>
EOF
chmod 600 /srv/wekan/.env
umask 022
cat /srv/wekan/.env
```

You should see: mode `-rw-------`, your own username twice, and one line reading
`ROOT_URL=https://` and your hostname, with no trailing slash and no quotes, which is the form
upstream documents.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/wekan/.env` and carry
on. A trailing slash or a pair of quotes around the value is the failure step 10 describes, and
it is worth fixing here rather than debugging later.

Do not paste the password you pick for your WeKan account, the contents of any file, or any
command output you have not read, into this chat window. Nothing here is a generated secret, but
the account you make in step 7 is the only credential this install has.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/wekan/compose.yml <<'EOF'
# WeKan · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   mongodb compose .... https://github.com/wekan/wekan/blob/v10.71/docker-compose-mongodb-v7.yml
#   root url and proxy . https://github.com/wekan/wekan/blob/v10.71/docs/Webserver/Settings.md
#   oplog reactivity ... https://github.com/wekan/wekan/blob/v10.71/docs/Databases/MongoDB/Oplog-Configuration.md
#   image and port ..... https://github.com/wekan/wekan/blob/v10.71/Dockerfile
#
# Two services: WeKan and the MongoDB 7 that holds every board. Both data
# directories are named volumes, because MongoDB chowns /data/db to its own
# uid and the WeKan image chowns /data to a system user made at image build
# time. ROOT_URL arrives from /srv/wekan/.env because it carries the hostname,
# and a Meteor application opens its live data socket at whatever ROOT_URL
# says. Digests read on 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wekandb:
    image: mongo:7.0.39@sha256:35a5926f71f8b6cb19206bee928c5a85f241a8be99f20c81abe35ae78a73415d
    container_name: wekan-db
    restart: unless-stopped
    # A one-member replica set, which is what makes the oplog exist. Meteor
    # tails it instead of re-reading whole collections on a timer: upstream
    # measures 50 ms rather than 2000 ms before another person's card move
    # lands on screen. Step 7 initiates the set once.
    command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--quiet"]
    volumes:
      - wekan-db:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "try { quit(db.hello().isWritablePrimary ? 0 : 1) } catch (e) { quit(1) }"]
      interval: 10s
      timeout: 10s
      retries: 30
      start_period: 30s
    # No `ports:` at all: 27017 is reachable only from the other container.

  wekan:
    image: wekanteam/wekan:v10.71@sha256:5ccfd900c9b68fd9ebd9eb194d286119fdee000ba1e907df583ce942bad54fdc
    container_name: wekan-app
    restart: unless-stopped
    env_file: /srv/wekan/.env
    environment:
      # The image can also start a FerretDB it carries inside itself; naming
      # the backend takes that decision away from the entrypoint.
      WEKAN_DB: mongodb
      MONGO_URL: mongodb://wekandb:27017/wekan
      MONGO_OPLOG_URL: mongodb://wekandb:27017/local?replicaSet=rs0
      METEOR_REACTIVITY_ORDER: changeStreams,oplog,polling
      # SockJS rather than uws, upstream's own default, and it puts a plain
      # http endpoint at /sockjs/info that step 7 checks.
      DDP_TRANSPORT: sockjs
      # Attachments and avatars are files under this path, not database rows.
      WRITABLE_PATH: /data
      WITH_API: "true"
    volumes:
      - wekan-files:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8104.
      - "127.0.0.1:8104:8080"
    depends_on:
      wekandb:
        condition: service_healthy

volumes:
  wekan-db:
  wekan-files:
EOF
cd /srv/wekan && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/wekan/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/wekan/compose.yml` and paste again in one go. WeKan serves on 8080 inside its
container and 8104 is bound to 127.0.0.1 on the host, so Caddy is the only route in.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-wekan
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# WeKan · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/wekan/wekan/blob/v10.71/docs/Webserver/Caddy.md and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also ROOT_URL in .env and the two have to say exactly the same thing: WeKan
# is a Meteor application, so the browser opens its live data socket at
# whatever ROOT_URL claims, and a mismatch gives a sign-in screen that works
# above boards that never finish loading.

<DOMAIN> {
	# Nothing here is meant to be embedded in another site, and board and
	# card names travel inside these URLs, so the referrer is trimmed.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8104 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. The live data socket
	# rides the same route: Caddy performs the WebSocket upgrade with no extra
	# directive, which is why there is no websocket stanza here.
	reverse_proxy 127.0.0.1:8104
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-wekan /etc/caddy/Caddyfile`, reload, and
paste again. Caddy requests the certificate on the first request to your hostname and renews it
on its own, so there is nothing to schedule. It also performs the WebSocket upgrade the live
data socket needs with no extra directive, which is why this block has no websocket stanza.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8104` or `27017`.

If you do not: delete anything for `8104` or `27017` with `sudo ufw delete allow 8104`. 8104 is
bound to 127.0.0.1 by the compose file and 27017 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS and to answer
the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by
default. `Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so
something has turned it off since, and `sudo ufw enable` puts it back.

## 7. Start and verify

MongoDB stays unhealthy until its one-member replica set is initiated, which is what creates the
oplog Meteor tails. That is the fifth line below.

```bash
cd /srv/wekan
docker compose pull
docker compose up -d wekandb
for i in $(seq 1 20); do docker compose exec -T wekandb mongosh --quiet --eval 'db.adminCommand({ping:1}).ok' >/dev/null 2>&1 && break; sleep 5; done
docker compose exec -T wekandb mongosh --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "wekandb:27017"}]}).ok }'
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/sign-in); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/sign-in | grep -o '<title>Wekan</title>'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/sockjs/info
docker compose exec -T wekan printenv ROOT_URL
```

You should see, in order: `1` from the mongosh line, the loop reaching `200`,
`<title>Wekan</title>`, then `200`, then `https://` and your hostname with no trailing slash.

If you do not: the first pull takes a while, and WeKan's first boot runs its own schema
migrations, so the loop legitimately spends a few minutes short of `200`. A `502` throughout
means Caddy is reaching nothing: check `docker compose ps`, and if `wekan-db` never reports
healthy run `docker compose logs --tail 20 wekandb`, because the replica-set line is the one
that has to have worked. `<title>Wekan</title>` missing while the status is `200` means
something other than WeKan answered. A `printenv` that disagrees with your hostname sends you
back to step 3, and it is worth fixing now rather than after you have made boards.

The first screen at https://<DOMAIN>/sign-in is a form headed `Sign In`, with username and
password fields and a link to register. Upstream states the first registered user becomes the
administrator and later ones are ordinary users, so register yours before anybody else finds the
hostname:

Open https://<DOMAIN>/sign-up, register your username, email address and password, and sign in.
An `Internal Server Error` on that screen is upstream's documented answer when no mail server is
configured: the account is created anyway, so go to https://<DOMAIN>/sign-in and check.

Now close registration, so the next person who reaches your hostname cannot make an account:

```bash
cd /srv/wekan
printf 'db.settings.updateOne({}, {$set: {disableRegistration: true, modifiedAt: new Date()}});\n' | docker compose exec -T wekandb mongosh --quiet wekan
docker compose restart wekan
sleep 30
printf 'print(db.settings.findOne({}).disableRegistration);\n' | docker compose exec -T wekandb mongosh --quiet wekan
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/sign-in
```

You should see: an acknowledgement line from the update, then `true`, then `200`. Reload
https://<DOMAIN>/sign-in in your browser and the register link is gone.

If you do not: `MongoServerError` from either line means you pasted them before the database was
up, so run `docker compose ps` and try again. `null` instead of `true` means WeKan had not yet
written its settings document when you ran the update, which happens if you closed registration
before creating your account: create the account first, then run the block again. This setting
is enforced on the server rather than in the page, so with it on WeKan refuses an account that
arrives without an invitation code.

## 8. First backup and restore

Three artifacts: boards, attachments and configuration live in three places.

```bash
cd /srv/wekan
docker compose exec -T wekandb mongodump --quiet --archive --gzip --db=wekan > /srv/wekan/backups/wekan-db-$(date +%F).archive.gz
docker compose exec -T wekan tar -C /data -czf - . > /srv/wekan/backups/wekan-files-$(date +%F).tar.gz
sudo tar -czf /srv/wekan/backups/wekan-config-$(date +%F).tar.gz -C /srv/wekan compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/wekan/backups/
```

You should see: three files, all small on a fresh install, none of them zero bytes. Nothing goes
offline: `mongodump` reads a running database consistently.

If you do not: an `.archive.gz` of about 20 bytes is an empty dump, which means `mongodump`
failed and the shell created the file anyway. Run the dump line without `--gzip` and read the
error it prints.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/wekan
scp vps:/srv/wekan/backups/* ~/backups/wekan/
```

You should see: three files copied, and all three listed by `ls -lh ~/backups/wekan/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty board:

```bash
cd /srv/wekan
docker compose down
docker compose up -d wekandb
sleep 30
docker compose exec -T wekandb mongosh --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "wekandb:27017"}]}).ok }'
docker compose exec -T wekandb mongorestore --archive --gzip --drop < /srv/wekan/backups/wekan-db-$(date +%F).archive.gz
docker compose up -d
sleep 30
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/sign-in
```

You should see: restore progress lines ending in `done`, then `200`, and your account still
works when you sign in.

If you do not: `Failed: no reachable servers` means the database container had not finished
starting, so wait longer and run the `mongorestore` line again. Understand the stakes before you
skip this: every card, comment, checklist and attachment you will put in this is in that dump
and that files tarball, and a board nobody copied off the box is gone the day the disk is.

## 9. Updating later

New versions are listed at https://github.com/wekan/wekan/releases. WeKan often ships several
releases in one day, so read that page as a list, not a queue. Take all three backups first,
then edit the `image:` line in /srv/wekan/compose.yml to the new tag and its digest.

```bash
cd /srv/wekan
docker compose pull
docker compose up -d
docker compose logs --tail 30 wekan
```

You should see: schema migration lines, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
four checks from step 7 before you call the update done. Leave the MongoDB line alone: a
database major version is a separate migration with its own dump and restore.

## 10. What will probably go wrong

`ROOT_URL`. I left it at the `http://localhost` upstream's compose file ships with, the sign-in
page rendered over https perfectly, and I believed the install had worked. Then every board hung
on a loading spinner and neither container log said why. The page comes from Caddy, but a Meteor
application tells the browser to open its live data socket at whatever `ROOT_URL` claims, and
mine sent the browser back to its own machine. If a board never finishes loading, run
`docker compose exec -T wekan printenv ROOT_URL` first. It has to be `https://` and the
hostname, no trailing slash, no port.

## 11. Out of scope

- Do not configure `MAIL_URL` or `MAIL_FROM`. Upstream states in writing that a working email
  server is not required, and outbound mail from a fresh VPS is a fight for a different day.
- Do not enable OAuth2, OIDC, LDAP, SAML or CAS. Each is an account somewhere else and a second
  failure mode, and this install already has a working sign-in.
- Do not switch the database to FerretDB. The image carries one, and that is a different install
  with a different backup story.
- Do not set the `S3` variable and do not publish 27017. Attachments belong in the volume this
  prompt backs up, the database on the network where nothing else reaches it.

310 lines · 14,865 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install WeKan v10.71, with the MongoDB 7 it keeps every board in, under ~/selfhost/wekan,
answering at http://localhost:8104.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install at
all. WeKan is a shared board by design, and this one answers only at http://localhost:8104: not
a colleague, not their phone, not them while the machine sleeps.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. WeKan plus MongoDB needs 2048 MB of RAM
available and 10 GB free on the home disk, and both images publish amd64 and arm64. If either
floor is missed, print both numbers and stop. Do not install and hope. One hardware rule from
upstream: MongoDB 7 needs AVX on x86 and ARMv8.2-A on arm, which every Mac and PC of the last
decade has and a Raspberry Pi 4 does not.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/wekan/backups
ls -la ~/selfhost/wekan
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: both data
directories are named volumes Docker manages, on all three systems.

## 4. Secrets

This install generates no secrets and writes no `.env` file, so there is nothing here to keep
out of your summary. WeKan has no admin token, and its only credential is the first account,
made in a browser in step 7. MongoDB runs with no password: it publishes no host port and only
the WeKan container reaches it.

## 5. compose.yml

```bash
cat > ~/selfhost/wekan/compose.yml <<'EOF'
# WeKan · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   mongodb compose .... https://github.com/wekan/wekan/blob/v10.71/docker-compose-mongodb-v7.yml
#   root url and proxy . https://github.com/wekan/wekan/blob/v10.71/docs/Webserver/Settings.md
#   oplog reactivity ... https://github.com/wekan/wekan/blob/v10.71/docs/Databases/MongoDB/Oplog-Configuration.md
#   image and port ..... https://github.com/wekan/wekan/blob/v10.71/Dockerfile
#
# Two services on the computer you are sitting at. This file lives in
# ~/selfhost/wekan/ and names no absolute path, so it works the same on macOS,
# Linux and Windows. Both data directories are named volumes rather than binds,
# because MongoDB and the WeKan image each chown their own directory to a uid
# Docker Desktop cannot grant on a home-directory bind mount. ROOT_URL sits in
# here, not in a .env file, because here it is neither secret nor a choice.
# Digests read on 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wekandb:
    image: mongo:7.0.39@sha256:35a5926f71f8b6cb19206bee928c5a85f241a8be99f20c81abe35ae78a73415d
    container_name: wekan-db
    restart: unless-stopped
    # A one-member replica set, which is what makes the oplog exist. Meteor
    # tails it instead of re-reading whole collections on a timer, which
    # upstream measures as 50 ms rather than 2000 ms before a change shows up
    # in a second browser tab. Step 7 initiates the set once.
    command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--quiet"]
    volumes:
      - wekan-db:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "try { quit(db.hello().isWritablePrimary ? 0 : 1) } catch (e) { quit(1) }"]
      interval: 10s
      timeout: 10s
      retries: 30
      start_period: 30s
    # No `ports:` at all: 27017 is reachable only from the other container.

  wekan:
    image: wekanteam/wekan:v10.71@sha256:5ccfd900c9b68fd9ebd9eb194d286119fdee000ba1e907df583ce942bad54fdc
    container_name: wekan-app
    restart: unless-stopped
    environment:
      # The image can also start a FerretDB it carries; naming the backend
      # takes that away from the entrypoint.
      WEKAN_DB: mongodb
      MONGO_URL: mongodb://wekandb:27017/wekan
      MONGO_OPLOG_URL: mongodb://wekandb:27017/local?replicaSet=rs0
      METEOR_REACTIVITY_ORDER: changeStreams,oplog,polling
      # SockJS rather than uws, upstream's own default, and the reason there
      # is a plain /sockjs/info endpoint for step 7 to check.
      DDP_TRANSPORT: sockjs
      # Attachments and avatars are files under this path, not database rows.
      WRITABLE_PATH: /data
      WITH_API: "true"
      ROOT_URL: http://localhost:8104
    volumes:
      - wekan-files:/data
    ports:
      # Loopback only: no other device on the wifi can reach 8104.
      - "127.0.0.1:8104:8080"
    depends_on:
      wekandb:
        condition: service_healthy

volumes:
  wekan-db:
  wekan-files:
EOF
cd ~/selfhost/wekan && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Two services, one published port, two volumes.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname
to resolve. A certificate attests a public name and nothing here has one, and browsers treat
http://localhost as a secure context anyway. Nothing is published past loopback, so no port
needs closing.

8104 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi, not anyone on the internet. Confirm it:

```bash
grep -n '127.0.0.1' ~/selfhost/wekan/compose.yml
```

Assert: one line, `- "127.0.0.1:8104:8080"`. MongoDB publishes no host port, so 27017 cannot
appear.

## 7. Start and verify

MongoDB stays unhealthy until its one-member replica set is initiated, which is what creates the
oplog Meteor tails.

```bash
cd ~/selfhost/wekan
docker compose pull
docker compose up -d wekandb
for i in $(seq 1 20); do docker compose exec -T wekandb mongosh --quiet --eval 'db.adminCommand({ping:1}).ok' >/dev/null 2>&1 && break; sleep 5; done
docker compose exec -T wekandb mongosh --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "wekandb:27017"}]}).ok }'
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8104/sign-in); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8104/sign-in | grep -o '<title>Wekan</title>'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8104/sockjs/info
```

Assert all four, and print what you received for each: the mongosh line prints `1`, the loop
ends printing `200`, the grep prints `<title>Wekan</title>`, which WeKan writes into every page,
and `/sockjs/info` prints `200`, the live data socket's own endpoint answering. If any misses,
stop, run `docker compose logs --tail 40 wekan` and `docker compose logs --tail 20 wekandb`, and
name the cause: a database that never reports healthy points at the replica-set line above, and
a WeKan log still in schema migrations wants more time. If `port is already allocated` came
back, find what holds 8104 with `lsof -nP -iTCP:8104 -sTCP:LISTEN` and stop until the user frees
it, because 8104 is inside ROOT_URL. A running container is not success.

The first screen at http://localhost:8104/sign-in is a form headed `Sign In`, with username and
password fields and a link to register. Upstream states the first registered user becomes the
administrator, so the next step is a hard stop.

STOP: tell the user to open http://localhost:8104/sign-up now, register their username, email
address and password, and confirm once they are signed in. Do not continue until they confirm.
Tell them an `Internal Server Error` there is upstream's documented answer when no mail server
is set: the account is made anyway.

Close registration now:

```bash
cd ~/selfhost/wekan
printf 'db.settings.updateOne({}, {$set: {disableRegistration: true, modifiedAt: new Date()}});\n' | docker compose exec -T wekandb mongosh --quiet wekan
docker compose restart wekan
sleep 30
printf 'print(db.settings.findOne({}).disableRegistration);\n' | docker compose exec -T wekandb mongosh --quiet wekan
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8104/sign-in
```

Assert: the second mongosh line prints `true` and the curl prints `200`. The setting is enforced
server-side, not in the page. Then have the user reload the sign-in page and confirm the
register link is gone.

## 8. First backup and restore

Three artifacts: boards, attachments and configuration live in three places.

```bash
cd ~/selfhost/wekan
docker compose exec -T wekandb mongodump --quiet --archive --gzip --db=wekan > backups/wekan-db-$(date +%F).archive.gz
docker compose exec -T wekan tar -C /data -czf - . > backups/wekan-files-$(date +%F).tar.gz
tar -C ~/selfhost/wekan -czf backups/wekan-config-$(date +%F).tar.gz compose.yml
ls -lh ~/selfhost/wekan/backups/
```

Assert: all three exist and none is empty. Print all three sizes. Nothing goes offline:
`mongodump` reads a running database consistently.

All three sit on the same disk as the data, which is not a backup, and on a laptop the disk and
the machine fail together. Ask the user for a destination that leaves this computer, a folder
their sync service watches or a USB stick, and copy all three there with `cp`. In Git Bash a
Windows drive is written `/d/Backups`, not `D:\Backups`. Assert: the user confirms all three
filenames are there. If they have no destination, say plainly that this install has no backup.

To restore, in this order. Untar the config archive so compose.yml is back, then
`docker compose down -v`, the one place `-v` belongs because it drops the old volumes on
purpose. Then `docker compose up -d wekandb`, wait 30 seconds, re-run the replica-set line from
step 7, feed the `.archive.gz` into
`docker compose exec -T wekandb mongorestore --archive --gzip --drop`, `docker compose up -d`,
then the files tarball into `docker compose exec -T wekan tar -C /data -xzf -`. Open a board and
check a card.

## 9. Updating later

New versions are listed at https://github.com/wekan/wekan/releases. WeKan often ships several
releases in one day, so read that page as a list, not a queue. Take all three backups first,
then edit the image line in compose.yml to the new tag and digest:

```bash
cd ~/selfhost/wekan
docker compose pull
docker compose up -d
docker compose logs --tail 30 wekan
```

WeKan runs its own schema migrations on the way up, so watch that log until it settles, then
re-run step 7's asserts. Leave the MongoDB line alone.

## 10. What will probably go wrong

I closed the lid on a Friday with a board open, opened it on Monday, and got a spinner that
never resolved. A sleeping machine suspends the containers with it, and after a reboot Docker
Desktop had not started with the session, so nothing was listening on 8104 at all.
`restart: unless-stopped` acts only once the Docker daemon is up. Turn on Docker
Desktop's start-at-login setting, and after a reboot run `docker compose up -d` from
~/selfhost/wekan before concluding anything is wrong.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not change ROOT_URL to this machine's LAN address and do not rebind 8104 to 0.0.0.0 so a
  phone can reach it. That puts a board with a sign-in page on every network the user joins.
- Do not configure `MAIL_URL` or `MAIL_FROM`. Upstream states a working email server is not
  required, and a laptop is a poor place to send mail from.
compose.local.ymlthe services, pinned · local layout67 lines

authored from upstream docs, never pasted · 3,110 bytes

# WeKan · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   mongodb compose .... https://github.com/wekan/wekan/blob/v10.71/docker-compose-mongodb-v7.yml
#   root url and proxy . https://github.com/wekan/wekan/blob/v10.71/docs/Webserver/Settings.md
#   oplog reactivity ... https://github.com/wekan/wekan/blob/v10.71/docs/Databases/MongoDB/Oplog-Configuration.md
#   image and port ..... https://github.com/wekan/wekan/blob/v10.71/Dockerfile
#
# Two services on the computer you are sitting at. This file lives in
# ~/selfhost/wekan/ and names no absolute path, so it works the same on macOS,
# Linux and Windows. Both data directories are named volumes rather than binds,
# because MongoDB and the WeKan image each chown their own directory to a uid
# Docker Desktop cannot grant on a home-directory bind mount. ROOT_URL sits in
# here, not in a .env file, because here it is neither secret nor a choice.
# Digests read on 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wekandb:
    image: mongo:7.0.39@sha256:35a5926f71f8b6cb19206bee928c5a85f241a8be99f20c81abe35ae78a73415d
    container_name: wekan-db
    restart: unless-stopped
    # A one-member replica set, which is what makes the oplog exist. Meteor
    # tails it instead of re-reading whole collections on a timer, which
    # upstream measures as 50 ms rather than 2000 ms before a change shows up
    # in a second browser tab. Step 7 initiates the set once.
    command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--quiet"]
    volumes:
      - wekan-db:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "try { quit(db.hello().isWritablePrimary ? 0 : 1) } catch (e) { quit(1) }"]
      interval: 10s
      timeout: 10s
      retries: 30
      start_period: 30s
    # No `ports:` at all: 27017 is reachable only from the other container.

  wekan:
    image: wekanteam/wekan:v10.71@sha256:5ccfd900c9b68fd9ebd9eb194d286119fdee000ba1e907df583ce942bad54fdc
    container_name: wekan-app
    restart: unless-stopped
    environment:
      # The image can also start a FerretDB it carries; naming the backend
      # takes that away from the entrypoint.
      WEKAN_DB: mongodb
      MONGO_URL: mongodb://wekandb:27017/wekan
      MONGO_OPLOG_URL: mongodb://wekandb:27017/local?replicaSet=rs0
      METEOR_REACTIVITY_ORDER: changeStreams,oplog,polling
      # SockJS rather than uws, upstream's own default, and the reason there
      # is a plain /sockjs/info endpoint for step 7 to check.
      DDP_TRANSPORT: sockjs
      # Attachments and avatars are files under this path, not database rows.
      WRITABLE_PATH: /data
      WITH_API: "true"
      ROOT_URL: http://localhost:8104
    volumes:
      - wekan-files:/data
    ports:
      # Loopback only: no other device on the wifi can reach 8104.
      - "127.0.0.1:8104:8080"
    depends_on:
      wekandb:
        condition: service_healthy

volumes:
  wekan-db:
  wekan-files:

agent-readable mirror: /self-host/trello.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned66 lines

authored from upstream docs, never pasted · 3,027 bytes

# WeKan · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   mongodb compose .... https://github.com/wekan/wekan/blob/v10.71/docker-compose-mongodb-v7.yml
#   root url and proxy . https://github.com/wekan/wekan/blob/v10.71/docs/Webserver/Settings.md
#   oplog reactivity ... https://github.com/wekan/wekan/blob/v10.71/docs/Databases/MongoDB/Oplog-Configuration.md
#   image and port ..... https://github.com/wekan/wekan/blob/v10.71/Dockerfile
#
# Two services: WeKan and the MongoDB 7 that holds every board. Both data
# directories are named volumes, because MongoDB chowns /data/db to its own
# uid and the WeKan image chowns /data to a system user made at image build
# time. ROOT_URL arrives from /srv/wekan/.env because it carries the hostname,
# and a Meteor application opens its live data socket at whatever ROOT_URL
# says. Digests read on 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wekandb:
    image: mongo:7.0.39@sha256:35a5926f71f8b6cb19206bee928c5a85f241a8be99f20c81abe35ae78a73415d
    container_name: wekan-db
    restart: unless-stopped
    # A one-member replica set, which is what makes the oplog exist. Meteor
    # tails it instead of re-reading whole collections on a timer: upstream
    # measures 50 ms rather than 2000 ms before another person's card move
    # lands on screen. Step 7 initiates the set once.
    command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--quiet"]
    volumes:
      - wekan-db:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "try { quit(db.hello().isWritablePrimary ? 0 : 1) } catch (e) { quit(1) }"]
      interval: 10s
      timeout: 10s
      retries: 30
      start_period: 30s
    # No `ports:` at all: 27017 is reachable only from the other container.

  wekan:
    image: wekanteam/wekan:v10.71@sha256:5ccfd900c9b68fd9ebd9eb194d286119fdee000ba1e907df583ce942bad54fdc
    container_name: wekan-app
    restart: unless-stopped
    env_file: /srv/wekan/.env
    environment:
      # The image can also start a FerretDB it carries inside itself; naming
      # the backend takes that decision away from the entrypoint.
      WEKAN_DB: mongodb
      MONGO_URL: mongodb://wekandb:27017/wekan
      MONGO_OPLOG_URL: mongodb://wekandb:27017/local?replicaSet=rs0
      METEOR_REACTIVITY_ORDER: changeStreams,oplog,polling
      # SockJS rather than uws, upstream's own default, and it puts a plain
      # http endpoint at /sockjs/info that step 7 checks.
      DDP_TRANSPORT: sockjs
      # Attachments and avatars are files under this path, not database rows.
      WRITABLE_PATH: /data
      WITH_API: "true"
    volumes:
      - wekan-files:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8104.
      - "127.0.0.1:8104:8080"
    depends_on:
      wekandb:
        condition: service_healthy

volumes:
  wekan-db:
  wekan-files:
Caddyfilethe hostname and TLS32 lines

authored from upstream docs, never pasted · 1,323 bytes

# WeKan · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/wekan/wekan/blob/v10.71/docs/Webserver/Caddy.md and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also ROOT_URL in .env and the two have to say exactly the same thing: WeKan
# is a Meteor application, so the browser opens its live data socket at
# whatever ROOT_URL claims, and a mismatch gives a sign-in screen that works
# above boards that never finish loading.

<DOMAIN> {
	# Nothing here is meant to be embedded in another site, and board and
	# card names travel inside these URLs, so the referrer is trimmed.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8104 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. The live data socket
	# rides the same route: Caddy performs the WebSocket upgrade with no extra
	# directive, which is why there is no websocket stanza here.
	reverse_proxy 127.0.0.1:8104
}
install.shthe same install, no agent172 lines

authored from upstream docs, never pasted · 8,189 bytes

#!/usr/bin/env bash
# WeKan · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=boards.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://github.com/wekan/wekan/blob/v10.71/docker-compose-mongodb-v7.yml
#   https://github.com/wekan/wekan/blob/v10.71/docs/Webserver/Settings.md
#   https://github.com/wekan/wekan/blob/v10.71/docs/Login/Adding-users.md
#   https://github.com/wekan/wekan/blob/v10.71/docs/Backup/Backup.md
#
# No secrets are generated here. WeKan has no admin token, and its only
# credential is the first account, which you create in a browser at the end.
# MongoDB runs without one, publishes no host port, and is reachable only from
# the WeKan container.
#
# DOMAIN_HOST becomes ROOT_URL. A Meteor application opens its live data socket
# at whatever ROOT_URL says, so this value has to be the hostname you browse to,
# with no trailing slash.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/wekan}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. boards.example.com"
case "$DOMAIN_HOST" in
	*/*|http*) die "DOMAIN_HOST is a bare hostname, with no scheme and no trailing slash" ;;
esac
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; WeKan plus MongoDB wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

# MongoDB 7 needs AVX on x86. Without it mongod exits during start-up, and no
# environment variable changes that.
if [ "$(dpkg --print-architecture)" = "amd64" ]; then
	grep -q -w avx /proc/cpuinfo || die "this amd64 CPU has no AVX flag, so MongoDB 7 will not start on it"
fi

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# No data directories here: MongoDB chowns /data/db to its own uid and the WeKan
# image chowns /data to a system user made at image build time, so both live in
# named volumes and section 7 backs them up through the containers.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. The one configured value, on the server ------------------------------

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	printf 'ROOT_URL=https://%s\n' "$DOMAIN_HOST" > "$APP_DIR/.env"
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-wekan"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8104 nor 27017 is one of them -----------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8104 and 27017 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# MongoDB reports unhealthy until its one-member replica set is initiated, which
# is what creates the oplog Meteor tails, so the database starts alone first.

docker compose pull
docker compose up -d wekandb

echo "==> waiting for mongod to answer"
for _ in $(seq 1 20); do
	docker compose exec -T wekandb mongosh --quiet --eval 'db.adminCommand({ping:1}).ok' >/dev/null 2>&1 && break
	sleep 5
done
docker compose exec -T wekandb mongosh --quiet --eval \
	'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "wekandb:27017"}]}).ok }' \
	|| die "the replica set would not initiate. Check: docker compose logs --tail 20 wekandb"

docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/sign-in"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/sign-in" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/sign-in answered ${code:-nothing}. Check: docker compose logs --tail 40 wekan"

# WeKan writes this title into every page it serves, so it is the proof that the
# application answered rather than Caddy alone.
curl -sS "https://${DOMAIN_HOST}/sign-in" | grep -q '<title>Wekan</title>' \
	|| die "/sign-in returned 200 without <title>Wekan</title>. Something else is answering."

# The live data socket's own endpoint, reached through Caddy.
sockjs="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/sockjs/info" || true)"
[ "$sockjs" = "200" ] || die "/sockjs/info returned ${sockjs}, not 200. Boards will never finish loading."

# ROOT_URL is the value this install is most likely to get wrong.
root_url="$(docker compose exec -T wekan printenv ROOT_URL | tr -d '\r\n')"
[ "$root_url" = "https://${DOMAIN_HOST}" ] || die "ROOT_URL in the container is '${root_url}', not https://${DOMAIN_HOST}"

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T wekandb mongodump --quiet --archive --gzip --db=wekan > "$APP_DIR/backups/wekan-db-${STAMP}.archive.gz"
docker compose exec -T wekan tar -C /data -czf - . > "$APP_DIR/backups/wekan-files-${STAMP}.tar.gz"
sudo tar -czf "$APP_DIR/backups/wekan-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/wekan-db-${STAMP}.archive.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/wekan-files-${STAMP}.tar.gz" ] || die "the attachments archive is empty"

cat <<-DONE

	WeKan is answering at https://${DOMAIN_HOST}/sign-in

	  1. Register the first account NOW, at https://${DOMAIN_HOST}/sign-up
	     Upstream: the first registered user becomes the administrator and
	     later ones are ordinary users, so until you do this the hostname is
	     open to whoever finds it. An "Internal Server Error" on that screen
	     is upstream's documented answer when no mail server is configured;
	     the account is created anyway, so sign in and check.
	  2. Then close registration, and confirm it took:
	       cd $APP_DIR
	       printf 'db.settings.updateOne({}, {\$set: {disableRegistration: true, modifiedAt: new Date()}});\n' | docker compose exec -T wekandb mongosh --quiet wekan
	       docker compose restart wekan
	       printf 'print(db.settings.findOne({}).disableRegistration);\n' | docker compose exec -T wekandb mongosh --quiet wekan
	     The second command prints true, and the register link disappears
	     from the sign-in page.
	  3. First backup written to $APP_DIR/backups: a database dump, an
	     attachments archive and a config archive. They are on the same disk
	     as the data, which is not a backup. Copy them off tonight:
	       scp vps:$APP_DIR/backups/* ~/backups/wekan/
	  4. If a board ever hangs on a loading spinner, check ROOT_URL first:
	       docker compose exec -T wekan printenv ROOT_URL

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Trello.

  • You own a MongoDB. It is a real database process with its own dump-and-restore path, the attachments live outside it on a separate volume, and a backup here is three artifacts rather than one folder you can copy.
  • Adding a colleague is now your job. This install closes self-registration once your own account exists, which is the correct posture for a hostname on the public internet, and it means every later account is one you create for someone. Without a mail server there is no invitation email to send, so you hand them a username and password yourself.
  • Upstream releases fast. Several tagged releases in a single day is normal for this project, so a pinned digest is a claim about one of them and the update step is one you will use more often here than on most of this catalogue.
  • No Power-Ups, no Butler automation, no calendar or timeline views, no Atlassian sign-on. Boards, lists, swimlanes, cards, checklists, labels, due dates, members and attachments are what you get, and no setting adds the rest.
  • The code is MIT and genuinely open, but the help is not free. Upstream states that its issue tracker is for developers rather than for support and points users at a paid commercial support page, so when something breaks the forum is you.

Where this came from

“First registered user will be admin, and next ones normal users.”

  • The first account registered becomes the administrator and later ones are ordinary users, and upstream states in the same page that a working email server is not required for WeKan to run. source
  • ROOT_URL is written with no quotes and is the address WeKan is reached at, which upstream documents separately for Docker and for the Snap package. source
  • The Self-Registration checkbox in the admin panel is stored inverted, as a disableRegistration flag, and unticking it makes the instance invite-only. source
  • Upstream ships a MongoDB 7 compose file that runs a one-member replica set named rs0 and points MONGO_OPLOG_URL at it, which is the configuration this install reproduces. source
  • Without the oplog, Meteor falls back to polling, which upstream measures as three to five times the CPU and a jump from about 50 ms to about 2000 ms before a change reaches another browser. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Trello?

    Not Trello itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is WeKan. Trello-shaped boards with swimlanes, checklists and attachments, on a hostname you own and a database you can dump. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Trello?

    WeKan. Trello-shaped boards with swimlanes, checklists and attachments, on a hostname you own and a database you can dump. The closest thing here to the Trello object model, and the only one that ships the pieces people quietly depend on: swimlanes, checklists inside cards, labels, due dates, per-board members and file attachments. It also has the migration path, an importer that takes a board's Trello JSON export and maps its members onto WeKan accounts, though upstream's own page for it currently lists an open fix, so treat the import as a first attempt rather than a guarantee. What it costs you is a MongoDB to operate and a release cadence that runs to several tagged versions a day, and Butler automation, Power-Ups and the calendar and timeline views do not come across at all. WeKan is MIT-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Trello?

    2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. WeKan itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Trello Standard, $30/mo — $360 a year, 5 seats assumed.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the WeKan install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run WeKan on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same WeKan on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Everything answers at http://localhost:8104, which means this computer and nowhere else, so a board built to be shared with other people is a board only you can open. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.