# Can I self-host monday.com?

**YES** — it's called Leantime. ONE EVENING setup · ~1.5 hours to running · 2 GB RAM minimum · $70/mo you stop paying ($840/yr on the Standard plan, 5 seats assumed).

Leantime authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/monday-com/

## Install prompt (Claude Code)

````text
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 Leantime 3.9.8 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. It becomes `LEAN_APP_URL`, the base every
Leantime redirect is built against, so changing it later means editing .env and restarting.

Leantime needs 2048 MB of RAM available and 10 GB free on /srv. It is PHP-FPM behind nginx in
front of MySQL; both images publish amd64 and arm64. Measure all four first:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
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 `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name that does not resolve, and failed attempts hit a rate limit.

## 2. Layout

The application image runs as `www-data`, uid 1000, so the two directories it writes to are
created owned by 1000:

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/leantime /srv/leantime/backups
sudo install -d -m 700 /srv/leantime/mysql
sudo install -d -m 750 -o 1000 -g 1000 /srv/leantime/userfiles /srv/leantime/public-userfiles
ls -la /srv/leantime
```

Assert: `ls -la` shows `backups` owned by the login user, `mysql` at mode `700`, and both
`userfiles` directories owned by uid 1000. Leave `mysql` alone: that image
chowns its own data directory on first start, and one already chowned refuses to initialise.

## 3. Secrets

Three secrets, all generated here: the MySQL root password, the `leantime` database user's
password, and `LEAN_SESSION_PASSWORD`, which salts every session cookie. Print none of them, keep
them out of your summary and out of every log line. Hex, not base64: Compose reads this file to
expand the `${...}` in compose.yml and treats an unquoted `#` as a comment, so a base64 secret
can lose its tail.

```bash
umask 077
cat > /srv/leantime/.env <<EOF
DOMAIN_NAME=<DOMAIN>
DB_ROOT_PASSWORD=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
LEAN_SESSION_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/leantime/.env
umask 022
ls -l /srv/leantime/.env
```

Assert: the file exists with mode `-rw-------` and the login user's name twice. It is never
mounted into a container; Compose reads it on the host from /srv/leantime.
`LEAN_SESSION_PASSWORD` is not in the database, so a restore without this file signs everyone
out.

## 4. compose.yml

```bash
cat > /srv/leantime/compose.yml <<'EOF'
# Leantime · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install .... https://docs.leantime.io/installation/docker
#   variable reference  https://github.com/Leantime/docker-leantime/blob/master/sample.env
#   backup & restore .. https://docs.leantime.io/installation/backup-restore
#
# Two services: Leantime's nginx-and-PHP-FPM image and the MySQL holding every
# project, task, goal and comment. Upstream keeps its data in named volumes;
# this file binds the two userfiles directories the backup page asks you to
# keep under /srv/leantime, and leaves MySQL on a directory the image chowns
# for itself. No plugin mount: upstream asks for one only if you install
# marketplace plugins, and this install does not. The app image runs as
# www-data, uid 1000, so those two are created owned by 1000. Digests read
# 2026-08-07; both images have arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  leantime_db:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: leantime-db
    restart: unless-stopped
    command: --character-set-server=UTF8MB4 --collation-server=UTF8MB4_unicode_ci
    environment:
      MYSQL_DATABASE: leantime
      MYSQL_USER: leantime
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - /srv/leantime/mysql:/var/lib/mysql
    healthcheck:
      # Runs inside the container, where that value already is an env var.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u leantime -p$$MYSQL_PASSWORD --silent"]
      start_period: 30s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  leantime:
    image: leantime/leantime:3.9.8@sha256:6150dd3e8a1e17f1ead8d462d31e26177fe906ce3602dbbbf6af5417ef809de3
    container_name: leantime
    restart: unless-stopped
    # Both of these come from upstream's compose file for this service.
    security_opt:
      - no-new-privileges:true
    cap_add:
      - CAP_CHOWN
      - CAP_SETGID
      - CAP_SETUID
    environment:
      LEAN_DB_HOST: leantime_db
      LEAN_DB_PORT: "3306"
      LEAN_DB_DATABASE: leantime
      LEAN_DB_USER: leantime
      LEAN_DB_PASSWORD: ${DB_PASSWORD}
      # Salts every session. Change it later and everyone is signed out.
      LEAN_SESSION_PASSWORD: ${LEAN_SESSION_PASSWORD}
      # Caddy terminates TLS in front, so the base URL carries its scheme.
      # Upstream needs this set for proxy installs; without it /install loops.
      LEAN_APP_URL: https://${DOMAIN_NAME}
      # true because Caddy serves this over https.
      LEAN_SESSION_SECURE: "true"
      LEAN_DEFAULT_TIMEZONE: UTC
    volumes:
      - /srv/leantime/userfiles:/var/www/html/userfiles
      - /srv/leantime/public-userfiles:/var/www/html/public/userfiles
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8163.
      - "127.0.0.1:8163:8080"
    depends_on:
      leantime_db:
        condition: service_healthy
EOF
cd /srv/leantime && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Two services, one published port, a database with no host
port. Do not add one.

## 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 takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-leantime
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Leantime · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.leantime.io/installation/docker 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 DOMAIN_NAME in .env, where it becomes LEAN_APP_URL: keep them identical.

<DOMAIN> {
	# The interface is HTML, JavaScript and JSON, and compresses well.
	encode zstd gzip

	# The image's own nginx already sends X-Frame-Options, a CSP and versions of
	# the three below. Caddy's header directive replaces rather than appends, so
	# a browser sees one of each and the two not named here pass through.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8163 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8163
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If it fails, restore
/etc/caddy/Caddyfile.before-leantime, reload, and report what it objected to. Caddy requests the
certificate on the first request and renews it on its own.

## 6. Firewall

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

```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, 443/udp is
HTTP/3, 8163 stays closed because compose binds it to 127.0.0.1, and 3306 because compose never
publishes it. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule mentioning 8163 or 3306.

## 7. Start and verify

MySQL initialises first and the app container does not start until it reports healthy, so the
first minute answers `502` through Caddy. The image serves `/healthCheck.php` from nginx,
outside the application router, which is why the loop asks for that rather than a page.

```bash
cd /srv/leantime
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/healthCheck.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/healthCheck.php
curl -sS https://<DOMAIN>/install | grep -c 'This script will set up your database' || true
```

Assert all three and print what you received: the loop ends on `200`; the second prints `Ok` and
nothing else; the third prints `1`. On any miss, stop, run
`docker compose logs --tail 40 leantime` and `docker compose logs --tail 20 leantime_db`, and
name the earlier step. A `502` that never clears means the database never reported healthy, which
is step 2. A `0` next to a `200` means the app answered and redirected, which is `LEAN_APP_URL`
in step 3 disagreeing with the hostname in step 5. A running container is not success.

The first screen at https://<DOMAIN> redirects to https://<DOMAIN>/install: the heading
`Installation` over the line
`This script will set up your database and create an administrator account`, then boxes for
`Email`, `First name`, `Last name` and `Company Name`, and an `Install` button. There is no
default account and no default password here; the first one is what the user creates there.

STOP: tell the user to open https://<DOMAIN>/install, fill in that form, press `Install`, then
set a password on the `Setting Account Details` screen it hands them next. Upstream wants 8
characters with an uppercase, a lowercase, a number and a symbol. Tell them to put it in their
password manager: nothing here can mail it back. Wait. Do not continue until they confirm they
are signed in.

Now prove the installer closed behind them:

```bash
curl -sS https://<DOMAIN>/install | grep -c 'This script will set up your database' || true
curl -sSL https://<DOMAIN>/ | grep -c '<label for="password">Password</label>' || true
```

Assert both: the first prints `0` and the second prints `1`. That `0` is the security assert
here: Leantime stops serving the installer once the user table exists, so a `1` means no account
was created and the form is still open on a public hostname. Stop and send the user back to it.
The `1` is the login form answering at the root.

## 8. First backup and restore

Two artifacts. The database holds every project, task, goal, wiki page and comment. The file
archive holds the uploads and the files that rebuild the service around them.

```bash
cd /srv/leantime
docker compose exec -T leantime_db sh -c 'mysqldump --single-transaction --no-tablespaces -u leantime -p"$MYSQL_PASSWORD" leantime' | gzip > /srv/leantime/backups/leantime-db-$(date +%F).sql.gz
sudo tar -czf /srv/leantime/backups/leantime-files-$(date +%F).tar.gz -C /srv/leantime compose.yml .env userfiles public-userfiles -C /etc/caddy Caddyfile
ls -lh /srv/leantime/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing goes offline:
`--single-transaction` snapshots a running InnoDB database consistently, `--no-tablespaces` is
there because the `leantime` user is not a superuser, and the password is read inside the
database container so it never reaches the host process list.

A backup on the same disk is not a backup, so run this from the user's machine:

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

To restore, in this order. Untar the file archive into /srv/leantime first, so .env is back
before any container starts: MySQL takes its passwords from it the moment it initialises an empty
data directory, and a missing .env means a database that never starts. Then
`docker compose down`, `sudo rm -rf /srv/leantime/mysql`, recreate it as in step 2,
`docker compose up -d leantime_db`, wait a minute for healthy, then
`gunzip -c /srv/leantime/backups/leantime-db-<date>.sql.gz | docker compose exec -T leantime_db sh -c 'mysql -u leantime -p"$MYSQL_PASSWORD" leantime'`,
then `docker compose up -d`. Tell the user the stakes at 2am: the dump is the whole plan, the
uploads are only in the file archive, and `LEAN_SESSION_PASSWORD` is in .env alone.

## 9. Updating later

New versions are listed at https://github.com/Leantime/leantime/releases. Leantime ships several
in a busy month and each migrates its own schema, so back up both first, then edit the image
line in /srv/leantime/compose.yml to the new tag and digest:

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

Watch that log until it settles, then re-run step 7's first two checks. If a version wants a
schema change it serves `/install/update` instead of the app; the user presses its button once.

## 10. What will probably go wrong

The first two minutes look like a broken reverse proxy. Compose holds the app container back
until MySQL reports healthy, MySQL is building its data directory from scratch, and until both
finish Caddy has nothing on 8163 to talk to, so every request returns `502 Bad Gateway`. I
read that as a Caddyfile mistake and restored /etc/caddy/Caddyfile from my own copy twice before
the health loop caught up on round nine. Let step 7's loop run all forty rounds; while
`docker compose ps` still shows `leantime-db` as `starting`, that `502` is the design working.

## 11. Out of scope

- Do not configure SMTP or set any `LEAN_EMAIL_` variable. Leantime runs with mail off: invitations
  and notifications go nowhere, and the admin hands new people their credentials directly.
- Do not enable LDAP or OIDC. Both need an identity provider this install does not have, and both
  change how the account from step 7 signs in.
- Do not install plugins from the Leantime marketplace. Some are paid extensions of this same
  install, a broken one takes the app down, and no volume is mounted to keep them.
- Do not set `LEAN_USE_S3`. Uploads belong on the two mounted directories step 8 backs up.
````

## Chat fallback

````text
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 Leantime 3.9.8 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. `<DOMAIN>` becomes `LEAN_APP_URL`, the base Leantime builds every
redirect against. It is not a label you can swap later without editing .env and restarting, and a
mismatch between it and the name you type in a browser is the single most common way this install
appears broken while running perfectly.

## 1. Preflight

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

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

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. If the RAM figure is
short, this is PHP-FPM, nginx and MySQL on one box; 1 GB will start and then be killed by the
kernel partway through your first project import.

## 2. Layout

The application image runs as `www-data`, uid 1000, so the two directories it writes to are
created owned by 1000 rather than by you.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/leantime /srv/leantime/backups
sudo install -d -m 700 /srv/leantime/mysql
sudo install -d -m 750 -o 1000 -g 1000 /srv/leantime/userfiles /srv/leantime/public-userfiles
ls -la /srv/leantime
```

You should see: `backups` owned by you, `mysql` at mode `drwx------` owned by root, and both
`userfiles` directories at `drwxr-x---` owned by uid 1000, which `ls` may print as a username if
your own account happens to be 1000.

If you do not: leave `mysql` owned by root on purpose. The MySQL image chowns its own data
directory the first time it starts, and one you have already chowned to yourself makes it refuse
to initialise. Do not chown the two `userfiles` directories to yourself either: the container
cannot write to them if you do, and uploads then fail with a permission error that names a path
inside the container rather than on the host.

## 3. Secrets

Three secrets: the MySQL root password, the MySQL password for the `leantime` database user, and
`LEAN_SESSION_PASSWORD`, which salts every session cookie. All three are generated here, on the
server, and all three go into a file only you can read.

Hex rather than base64 for all three. Docker Compose reads this file to expand the `${...}`
references in compose.yml, and its parser treats an unquoted `#` as the start of a comment, so a
base64 secret can silently lose its tail and leave you with a database password that is wrong in
a way nothing reports.

```bash
umask 077
cat > /srv/leantime/.env <<EOF
DOMAIN_NAME=<DOMAIN>
DB_ROOT_PASSWORD=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
LEAN_SESSION_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/leantime/.env
umask 022
ls -l /srv/leantime/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste.

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/leantime/.env` and carry on.
If the file already existed from an earlier attempt, this block has now overwritten all three
secrets, which is fine before the database exists and a problem afterwards: MySQL keeps the
password it was created with, so a changed `DB_PASSWORD` against an existing data directory
produces a connection error in the Leantime log rather than anything mentioning passwords.

Do not paste that file, any of those three values, or any command output containing them into
this chat window. Nothing in this install needs you to read a secret aloud to anybody, and the
chat path is the only one of the three where a secret can leave your machine by accident.

## 4. compose.yml

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

```bash
cat > /srv/leantime/compose.yml <<'EOF'
# Leantime · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install .... https://docs.leantime.io/installation/docker
#   variable reference  https://github.com/Leantime/docker-leantime/blob/master/sample.env
#   backup & restore .. https://docs.leantime.io/installation/backup-restore
#
# Two services: Leantime's nginx-and-PHP-FPM image and the MySQL holding every
# project, task, goal and comment. Upstream keeps its data in named volumes;
# this file binds the two userfiles directories the backup page asks you to
# keep under /srv/leantime, and leaves MySQL on a directory the image chowns
# for itself. No plugin mount: upstream asks for one only if you install
# marketplace plugins, and this install does not. The app image runs as
# www-data, uid 1000, so those two are created owned by 1000. Digests read
# 2026-08-07; both images have arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  leantime_db:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: leantime-db
    restart: unless-stopped
    command: --character-set-server=UTF8MB4 --collation-server=UTF8MB4_unicode_ci
    environment:
      MYSQL_DATABASE: leantime
      MYSQL_USER: leantime
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - /srv/leantime/mysql:/var/lib/mysql
    healthcheck:
      # Runs inside the container, where that value already is an env var.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u leantime -p$$MYSQL_PASSWORD --silent"]
      start_period: 30s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  leantime:
    image: leantime/leantime:3.9.8@sha256:6150dd3e8a1e17f1ead8d462d31e26177fe906ce3602dbbbf6af5417ef809de3
    container_name: leantime
    restart: unless-stopped
    # Both of these come from upstream's compose file for this service.
    security_opt:
      - no-new-privileges:true
    cap_add:
      - CAP_CHOWN
      - CAP_SETGID
      - CAP_SETUID
    environment:
      LEAN_DB_HOST: leantime_db
      LEAN_DB_PORT: "3306"
      LEAN_DB_DATABASE: leantime
      LEAN_DB_USER: leantime
      LEAN_DB_PASSWORD: ${DB_PASSWORD}
      # Salts every session. Change it later and everyone is signed out.
      LEAN_SESSION_PASSWORD: ${LEAN_SESSION_PASSWORD}
      # Caddy terminates TLS in front, so the base URL carries its scheme.
      # Upstream needs this set for proxy installs; without it /install loops.
      LEAN_APP_URL: https://${DOMAIN_NAME}
      # true because Caddy serves this over https.
      LEAN_SESSION_SECURE: "true"
      LEAN_DEFAULT_TIMEZONE: UTC
    volumes:
      - /srv/leantime/userfiles:/var/www/html/userfiles
      - /srv/leantime/public-userfiles:/var/www/html/public/userfiles
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8163.
      - "127.0.0.1:8163:8080"
    depends_on:
      leantime_db:
        condition: service_healthy
EOF
cd /srv/leantime && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/leantime/.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/leantime/compose.yml` and paste again in one go. A warning about `DOMAIN_NAME` being
unset means the first line of your .env still says `<DOMAIN>`.

## 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-leantime
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Leantime · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.leantime.io/installation/docker 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 DOMAIN_NAME in .env, where it becomes LEAN_APP_URL: keep them identical.

<DOMAIN> {
	# The interface is HTML, JavaScript and JSON, and compresses well.
	encode zstd gzip

	# The image's own nginx already sends X-Frame-Options, a CSP and versions of
	# the three below. Caddy's header directive replaces rather than appends, so
	# a browser sees one of each and the two not named here pass through.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8163 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8163
}
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-leantime /etc/caddy/Caddyfile`, reload,
and paste again. The usual cause is a `<DOMAIN>` you replaced in one place and not the other, so
the file now has a site block with an angle bracket in its name. Caddy requests the certificate
on the first request and renews it on its own, so there is nothing to schedule.

## 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 `8163` or `3306`.

If you do not: delete anything for `8163` or `3306` with `sudo ufw delete allow 8163`. 8163 is
bound to 127.0.0.1 by the compose file and 3306 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 before you go any further.

## 7. Start and verify

MySQL initialises its data directory first, and the application container does not start until
that database reports healthy, so the first minute or so answers `502` through Caddy. That is
expected, not a fault.

```bash
cd /srv/leantime
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/healthCheck.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/healthCheck.php
curl -sS https://<DOMAIN>/install | grep -c 'This script will set up your database' || true
```

You should see, in order: the loop climbing through `502` and reaching `200`, then the word `Ok`
on its own line, then `1`.

If you do not: a `502` that never clears means the database never reported healthy, so run
`docker compose logs --tail 20 leantime_db` first and `docker compose logs --tail 40 leantime`
second. A `0` where you wanted `1`, alongside a `200`, means Leantime answered and redirected you
somewhere else, which is `LEAN_APP_URL` in step 3 disagreeing with the hostname in step 5. Check
both with `grep DOMAIN_NAME /srv/leantime/.env` and `grep -n 'reverse_proxy' /etc/caddy/Caddyfile`.
A running container is not success; that `1` is.

Now open https://<DOMAIN> in a browser. It redirects to https://<DOMAIN>/install, which shows the
heading `Installation` over the line
`This script will set up your database and create an administrator account`, then boxes for
`Email`, `First name`, `Last name` and `Company Name`, and an `Install` button. Fill it in and
press `Install`. Leantime hands you a `Setting Account Details` screen next, where you choose the
password for that account: upstream wants at least 8 characters with an uppercase, a lowercase, a
number and a symbol. Put it in your password manager before you go on, because nothing on this
server can mail it back to you.

There is no default account and no default password anywhere in this install. The account you
created is the only one that exists.

Once you are signed in, prove the installer closed behind you:

```bash
curl -sS https://<DOMAIN>/install | grep -c 'This script will set up your database' || true
curl -sSL https://<DOMAIN>/ | grep -c '<label for="password">Password</label>' || true
```

You should see: `0`, then `1`.

If you do not: that `0` is the security check in this step. Leantime stops serving the installer
once the user table exists, so a `1` means no account was created and the install form is still
open on a public hostname. Go back to https://<DOMAIN>/install and finish it before you do
anything else. The `1` from the second command is the login form now answering at the root.

## 8. First backup and restore

Two artifacts. The database holds every project, task, goal, wiki page and comment. The file
archive holds the uploads and the three files that rebuild the service around them.

```bash
cd /srv/leantime
docker compose exec -T leantime_db sh -c 'mysqldump --single-transaction --no-tablespaces -u leantime -p"$MYSQL_PASSWORD" leantime' | gzip > /srv/leantime/backups/leantime-db-$(date +%F).sql.gz
sudo tar -czf /srv/leantime/backups/leantime-files-$(date +%F).tar.gz -C /srv/leantime compose.yml .env userfiles public-userfiles -C /etc/caddy Caddyfile
ls -lh /srv/leantime/backups/
```

You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline:
`--single-transaction` snapshots a running InnoDB database consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mysqldump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.
`Access denied; you need the PROCESS privilege` means `--no-tablespaces` went missing: the
`leantime` user is not a superuser and the dump needs that flag.

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/leantime
scp vps:/srv/leantime/backups/* ~/backups/leantime/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/leantime/`.

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 `vps` alias Prompt Zero created lives.

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

```bash
cd /srv/leantime
docker compose down
sudo rm -rf /srv/leantime/mysql
sudo install -d -m 700 /srv/leantime/mysql
docker compose up -d leantime_db
sleep 60
gunzip -c /srv/leantime/backups/leantime-db-$(date +%F).sql.gz | docker compose exec -T leantime_db sh -c 'mysql -u leantime -p"$MYSQL_PASSWORD" leantime'
docker compose up -d
sleep 30
curl -sS https://<DOMAIN>/healthCheck.php
```

You should see: no output from the `gunzip` line, then `Ok` from the last command, then your own
account still signing in at https://<DOMAIN>.

If you do not: `ERROR 1045 (28000): Access denied` means the database container had not finished
initialising, so wait another minute and run the `gunzip` line again. Understand the order before
you ever do this for real: .env has to be back on disk before MySQL starts, because MySQL takes
its passwords from that file the moment it initialises an empty data directory. The full restore
from a bare server is untar the file archive into /srv/leantime, then the five commands above.
`LEAN_SESSION_PASSWORD` lives in .env and nowhere else, so a dump restored without that file
signs everybody out.

## 9. Updating later

New versions are listed at https://github.com/Leantime/leantime/releases. Leantime ships several
in a busy month and each migrates its own schema on the way up, so take both backup artifacts
first, then edit the `image:` line in /srv/leantime/compose.yml to the new tag and its digest.

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

You should see: the container starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. If a version
wants a schema change it serves `/install/update` instead of the application, and you press that
page's button once; that is normal on a minor bump and it is why the backup comes first. Re-run
the health check from step 7 before you call the update done.

## 10. What will probably go wrong

The first two minutes look like a broken reverse proxy. Compose holds the app container back
until MySQL reports healthy, MySQL is building its data directory from scratch, and until both
finish Caddy has nothing on 8163 to talk to, so every request returns `502 Bad Gateway`. I read
that as a Caddyfile mistake and restored /etc/caddy/Caddyfile from my own copy twice before the
health loop caught up on round nine. Let step 7's loop run all forty rounds; while
`docker compose ps` still shows `leantime-db` as `starting`, that `502` is the design working.

## 11. Out of scope

- Do not configure SMTP or set any `LEAN_EMAIL_` variable. Leantime runs with mail off, and the
  cost is real: invitations and notifications go nowhere, so you hand new people their
  credentials yourself.
- Do not enable LDAP or OIDC. Both need an identity provider this install does not have, and both
  change how the account from step 7 signs in.
- Do not install plugins from the Leantime marketplace. Some are paid extensions of this same
  install, a broken one takes the app down, and no volume is mounted to keep them.
- Do not set `LEAN_USE_S3`. Uploads belong on the two mounted directories step 8 backs up.
````

## Local install prompt (your own computer, no server)

````text
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 Leantime 3.9.8, with the MySQL it keeps every project in, under ~/selfhost/leantime,
answering at http://localhost:8163.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
Leantime answers at http://localhost:8163 and nowhere else. It is built for a team to share, and
here nobody can be invited: not a colleague, not a client, not their own phone. It is a planner
for one.

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. Leantime plus MySQL needs 2048 MB of RAM
available and 10 GB free on the home disk, and both images have amd64 and arm64. On macOS and
Windows that figure is the host's. If either floor is missed, print both and stop.

## 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/leantime/{backups,userfiles,public-userfiles}
if [ "$(uname -s)" = "Linux" ]; then sudo chown 1000:1000 ~/selfhost/leantime/{userfiles,public-userfiles}; fi
ls -la ~/selfhost/leantime
```

Assert: `ls -la` shows all three folders. The app image runs as `www-data`, uid 1000, and on
Linux can only write to a folder that uid owns, which the guarded line arranges; on macOS and
Windows it is a no-op, because Docker Desktop handles ownership. MySQL has no folder: step 5
keeps its data in a volume Docker manages.

## 4. Secrets

Three secrets, all generated here: the MySQL root password, the `leantime` user's database
password, and `LEAN_SESSION_PASSWORD`, which salts every session. Print none of them and keep
them out of your summary and every log line. Hex, not base64: Compose reads this file and treats
an unquoted `#` as a comment.

```bash
umask 077
cat > ~/selfhost/leantime/.env <<EOF
DB_ROOT_PASSWORD=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
LEAN_SESSION_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/leantime/.env
umask 022
ls -l ~/selfhost/leantime/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these run the same on
all three systems, and Compose reads it here rather than mounting it. On Windows those mode bits
are advisory: NTFS does not enforce them, and the user's account is the boundary.

## 5. compose.yml

```bash
cat > ~/selfhost/leantime/compose.yml <<'EOF'
# Leantime · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install .... https://docs.leantime.io/installation/docker
#   variable reference  https://github.com/Leantime/docker-leantime/blob/master/sample.env
#   backup & restore .. https://docs.leantime.io/installation/backup-restore
#
# Two services, run from ~/selfhost/leantime/. The userfiles folders are
# relative binds so uploads stay visible in Finder or Explorer; MySQL's is a
# named volume: that image chowns /var/lib/mysql itself. No plugin mount:
# upstream asks for one only if you install marketplace plugins, and this
# install does not. Digests read 2026-08-07; both images have arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  leantime_db:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: leantime-db
    restart: unless-stopped
    command: --character-set-server=UTF8MB4 --collation-server=UTF8MB4_unicode_ci
    environment:
      MYSQL_DATABASE: leantime
      MYSQL_USER: leantime
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - leantime-mysqldata:/var/lib/mysql
    healthcheck:
      # Runs in the container, where that value is already an env var.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u leantime -p$$MYSQL_PASSWORD --silent"]
      start_period: 30s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  leantime:
    image: leantime/leantime:3.9.8@sha256:6150dd3e8a1e17f1ead8d462d31e26177fe906ce3602dbbbf6af5417ef809de3
    container_name: leantime
    restart: unless-stopped
    # Both come from upstream's compose file for this service.
    security_opt:
      - no-new-privileges:true
    cap_add:
      - CAP_CHOWN
      - CAP_SETGID
      - CAP_SETUID
    environment:
      LEAN_DB_HOST: leantime_db
      LEAN_DB_PORT: "3306"
      LEAN_DB_DATABASE: leantime
      LEAN_DB_USER: leantime
      LEAN_DB_PASSWORD: ${DB_PASSWORD}
      # Salts every session. Change it later and everyone is signed out.
      LEAN_SESSION_PASSWORD: ${LEAN_SESSION_PASSWORD}
      # Upstream needs this set for proxy installs; without it /install loops.
      LEAN_APP_URL: http://localhost:8163
      # false because this is http, not https.
      LEAN_SESSION_SECURE: "false"
      LEAN_DEFAULT_TIMEZONE: UTC
    volumes:
      - ./userfiles:/var/www/html/userfiles
      - ./public-userfiles:/var/www/html/public/userfiles
    ports:
      # Loopback only: no device on the wifi can reach 8163.
      - "127.0.0.1:8163:8080"
    depends_on:
      leantime_db:
        condition: service_healthy

volumes:
  leantime-mysqldata:
EOF
cd ~/selfhost/leantime && docker compose config >/dev/null && echo "compose OK"
```

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

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision: no hostname to
resolve, nothing public to certify, nothing published past loopback to close. Browsers treat
http://localhost as a secure context, so crypto in the page works without TLS. 8163 is bound to
127.0.0.1: not the phone, not a laptop on the wifi, nobody outside. For a tool built to
be shared that is the trade, and it is the point of this path rather than a defect.

```bash
grep -c '"127.0.0.1:' ~/selfhost/leantime/compose.yml
```

Assert: that prints `1`, the single published port. MySQL publishes no host port, so 3306 cannot
appear. The health check names 127.0.0.1 too, but inside the container, hence the quoted form.

## 7. Start and verify

MySQL initialises first and the app container waits for it, so nothing answers on 8163 for a
minute. `/healthCheck.php` is served by nginx outside the application router.

```bash
cd ~/selfhost/leantime
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8163/healthCheck.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8163/healthCheck.php
curl -sS http://localhost:8163/install | grep -c 'This script will set up your database' || true
```

Assert all three and print what you got: the loop ends on `200`, the second prints `Ok` and
nothing else, the third prints `1`. On any miss, stop, run
`docker compose logs --tail 40 leantime` and `docker compose logs --tail 20 leantime_db`, and name
the cause: a refused connection that never clears means the database never reported healthy;
`port is already allocated` means something holds 8163, which `lsof -nP -iTCP:8163 -sTCP:LISTEN`
names. A running container is not success.

The first screen at http://localhost:8163 redirects to /install: the heading `Installation` over
the line `This script will set up your database and create an administrator account`, then boxes
for `Email`, `First name`, `Last name` and `Company Name`, and an `Install` button. No default
account exists.

STOP: tell the user to open http://localhost:8163/install, fill that form in, press `Install`,
then set a password on the `Setting Account Details` screen next. Upstream wants 8 characters
with an uppercase, a lowercase, a number and a symbol; nothing here can mail it back, so it goes
in their password manager. Wait. Do not continue until they confirm.

Then prove the installer closed behind them:

```bash
curl -sS http://localhost:8163/install | grep -c 'This script will set up your database' || true
curl -sSL http://localhost:8163/ | grep -c '<label for="password">Password</label>' || true
```

Assert both: the first prints `0`, the second `1`. That `0` is the security assert here: Leantime
stops serving the installer once the user table exists. The `1` is the login form at the root.

## 8. First backup and restore

Two artifacts. The database holds every project, task, goal, wiki page and comment; the archive
holds the uploads and the two files that rebuild it.

```bash
cd ~/selfhost/leantime
docker compose exec -T leantime_db sh -c 'mysqldump --single-transaction --no-tablespaces -u leantime -p"$MYSQL_PASSWORD" leantime' | gzip > backups/leantime-db-$(date +%F).sql.gz
tar -C ~/selfhost/leantime -czf backups/leantime-files-$(date +%F).tar.gz compose.yml .env userfiles public-userfiles
ls -lh backups/
```

Assert: both exist and neither is empty. Print both sizes. Nothing goes offline:
`--single-transaction` snapshots a running InnoDB database, `--no-tablespaces` is there because
the `leantime` user is not a superuser, and the password is read in the database container.

Both 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 sync folder or
a USB stick, and copy both there with `cp`. In Git Bash a Windows drive is `/d/Backups`, not
`D:\Backups`. Assert: the user confirms both names are there, or say there is no backup.

To restore, in this order. Untar the archive into ~/selfhost/leantime first, so .env is back
before any container starts: MySQL reads its passwords from it when it initialises an empty
volume. Then `docker compose down -v`, the one place `-v` belongs, dropping the old volume on
purpose, then `docker compose up -d leantime_db`, wait a minute, then
`gunzip -c backups/leantime-db-<date>.sql.gz | docker compose exec -T leantime_db sh -c 'mysql -u leantime -p"$MYSQL_PASSWORD" leantime'`,
then `docker compose up -d`. Sign in and check a project is there. The dump is the whole plan;
`LEAN_SESSION_PASSWORD` is in .env alone.

## 9. Updating later

New versions are listed at https://github.com/Leantime/leantime/releases. Leantime ships several
in a busy month and each migrates its own schema, so back up both first, then edit the image
line:

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

Watch it until it settles, then re-run step 7's first two checks. If a version wants a schema
change it serves `/install/update`; the user presses it once.

## 10. What will probably go wrong

8163 is not only a port here, it is written into `LEAN_APP_URL`, and I learned that the noisy way.
Something else already held 8163, so I moved the published port to 8164, the container came up
clean, and every request bounced back to http://localhost:8163 and died: Leantime builds its
redirects from the address it was given, not the one you ask on. If 8163 changes, change both
lines in compose.yml, the `ports:` entry and `LEAN_APP_URL`, then
`docker compose up -d --force-recreate`.

## 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 rebind 8163 to 0.0.0.0 so a phone or colleague can reach it. That puts a login form
  holding the user's plans and client names on every network they join.
- Do not configure SMTP, enable LDAP or OIDC, or install marketplace plugins. Each needs
  something this install lacks, and no volume keeps a plugin across a restart.
````

## docker-compose.yml

```yaml
# Leantime · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install .... https://docs.leantime.io/installation/docker
#   variable reference  https://github.com/Leantime/docker-leantime/blob/master/sample.env
#   backup & restore .. https://docs.leantime.io/installation/backup-restore
#
# Two services: Leantime's nginx-and-PHP-FPM image and the MySQL holding every
# project, task, goal and comment. Upstream keeps its data in named volumes;
# this file binds the two userfiles directories the backup page asks you to
# keep under /srv/leantime, and leaves MySQL on a directory the image chowns
# for itself. No plugin mount: upstream asks for one only if you install
# marketplace plugins, and this install does not. The app image runs as
# www-data, uid 1000, so those two are created owned by 1000. Digests read
# 2026-08-07; both images have arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  leantime_db:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: leantime-db
    restart: unless-stopped
    command: --character-set-server=UTF8MB4 --collation-server=UTF8MB4_unicode_ci
    environment:
      MYSQL_DATABASE: leantime
      MYSQL_USER: leantime
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - /srv/leantime/mysql:/var/lib/mysql
    healthcheck:
      # Runs inside the container, where that value already is an env var.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u leantime -p$$MYSQL_PASSWORD --silent"]
      start_period: 30s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  leantime:
    image: leantime/leantime:3.9.8@sha256:6150dd3e8a1e17f1ead8d462d31e26177fe906ce3602dbbbf6af5417ef809de3
    container_name: leantime
    restart: unless-stopped
    # Both of these come from upstream's compose file for this service.
    security_opt:
      - no-new-privileges:true
    cap_add:
      - CAP_CHOWN
      - CAP_SETGID
      - CAP_SETUID
    environment:
      LEAN_DB_HOST: leantime_db
      LEAN_DB_PORT: "3306"
      LEAN_DB_DATABASE: leantime
      LEAN_DB_USER: leantime
      LEAN_DB_PASSWORD: ${DB_PASSWORD}
      # Salts every session. Change it later and everyone is signed out.
      LEAN_SESSION_PASSWORD: ${LEAN_SESSION_PASSWORD}
      # Caddy terminates TLS in front, so the base URL carries its scheme.
      # Upstream needs this set for proxy installs; without it /install loops.
      LEAN_APP_URL: https://${DOMAIN_NAME}
      # true because Caddy serves this over https.
      LEAN_SESSION_SECURE: "true"
      LEAN_DEFAULT_TIMEZONE: UTC
    volumes:
      - /srv/leantime/userfiles:/var/www/html/userfiles
      - /srv/leantime/public-userfiles:/var/www/html/public/userfiles
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8163.
      - "127.0.0.1:8163:8080"
    depends_on:
      leantime_db:
        condition: service_healthy
```

## compose.local.yml

```yaml
# Leantime · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install .... https://docs.leantime.io/installation/docker
#   variable reference  https://github.com/Leantime/docker-leantime/blob/master/sample.env
#   backup & restore .. https://docs.leantime.io/installation/backup-restore
#
# Two services, run from ~/selfhost/leantime/. The userfiles folders are
# relative binds so uploads stay visible in Finder or Explorer; MySQL's is a
# named volume: that image chowns /var/lib/mysql itself. No plugin mount:
# upstream asks for one only if you install marketplace plugins, and this
# install does not. Digests read 2026-08-07; both images have arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  leantime_db:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: leantime-db
    restart: unless-stopped
    command: --character-set-server=UTF8MB4 --collation-server=UTF8MB4_unicode_ci
    environment:
      MYSQL_DATABASE: leantime
      MYSQL_USER: leantime
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - leantime-mysqldata:/var/lib/mysql
    healthcheck:
      # Runs in the container, where that value is already an env var.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u leantime -p$$MYSQL_PASSWORD --silent"]
      start_period: 30s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  leantime:
    image: leantime/leantime:3.9.8@sha256:6150dd3e8a1e17f1ead8d462d31e26177fe906ce3602dbbbf6af5417ef809de3
    container_name: leantime
    restart: unless-stopped
    # Both come from upstream's compose file for this service.
    security_opt:
      - no-new-privileges:true
    cap_add:
      - CAP_CHOWN
      - CAP_SETGID
      - CAP_SETUID
    environment:
      LEAN_DB_HOST: leantime_db
      LEAN_DB_PORT: "3306"
      LEAN_DB_DATABASE: leantime
      LEAN_DB_USER: leantime
      LEAN_DB_PASSWORD: ${DB_PASSWORD}
      # Salts every session. Change it later and everyone is signed out.
      LEAN_SESSION_PASSWORD: ${LEAN_SESSION_PASSWORD}
      # Upstream needs this set for proxy installs; without it /install loops.
      LEAN_APP_URL: http://localhost:8163
      # false because this is http, not https.
      LEAN_SESSION_SECURE: "false"
      LEAN_DEFAULT_TIMEZONE: UTC
    volumes:
      - ./userfiles:/var/www/html/userfiles
      - ./public-userfiles:/var/www/html/public/userfiles
    ports:
      # Loopback only: no device on the wifi can reach 8163.
      - "127.0.0.1:8163:8080"
    depends_on:
      leantime_db:
        condition: service_healthy

volumes:
  leantime-mysqldata:
```

## Caddyfile

```text
# Leantime · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.leantime.io/installation/docker 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 DOMAIN_NAME in .env, where it becomes LEAN_APP_URL: keep them identical.

<DOMAIN> {
	# The interface is HTML, JavaScript and JSON, and compresses well.
	encode zstd gzip

	# The image's own nginx already sends X-Frame-Options, a CSP and versions of
	# the three below. Caddy's header directive replaces rather than appends, so
	# a browser sees one of each and the two not named here pass through.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8163 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8163
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Leantime · 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=plan.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.leantime.io/installation/docker
#   https://github.com/Leantime/docker-leantime/blob/master/sample.env
#   https://docs.leantime.io/installation/system-requirements
#   https://docs.leantime.io/installation/backup-restore
#
# Three secrets are generated here, on this machine: the MySQL root password,
# the MySQL password for the leantime database user, and LEAN_SESSION_PASSWORD,
# which salts every session cookie. All three go into /srv/leantime/.env with
# mode 600 and none is ever printed.
#
# DOMAIN_HOST is also LEAN_APP_URL, the base Leantime builds every redirect
# against, so it has to be the name you will actually use in a browser.
#
# This script stops one step short of a finished install on purpose: only a
# human can fill in the web installer at https://<DOMAIN_HOST>/install, which is
# where the first and only account is created. The summary at the end says so.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/leantime}"
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. plan.example.com"
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."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; PHP-FPM plus MySQL 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"

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 ----------------------------------------------------
#
# mysql stays root-owned: that image chowns its own data directory on first
# start and this script does not fight it. The two userfiles directories go to
# uid 1000, which is the www-data the application image runs as.

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

# --- 3. Generate the three secrets, on the server ----------------------------
#
# Hex for all three: Compose reads this file to expand the ${...} references in
# compose.yml and treats an unquoted # as the start of a comment, so a base64
# secret can lose its tail. Read them later with
#   sudo grep -E 'DB_PASSWORD|LEAN_SESSION_PASSWORD' /srv/leantime/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		DOMAIN_NAME=${DOMAIN_HOST}
		DB_ROOT_PASSWORD=$(openssl rand -hex 32)
		DB_PASSWORD=$(openssl rand -hex 32)
		LEAN_SESSION_PASSWORD=$(openssl rand -hex 32)
	ENVFILE
	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-leantime"
	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 8163 nor 3306 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; 8163 and 3306 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 -------------------------------------------------------------
#
# MySQL initialises its data directory, then the application container starts,
# because compose holds it back until the database reports healthy. Everything
# answers 502 through Caddy until both are up, which is expected.

docker compose pull
docker compose up -d

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

curl -sS "https://${DOMAIN_HOST}/healthCheck.php" | grep -q '^Ok$' \
	|| die "/healthCheck.php answered 200 without printing Ok. Check: docker compose logs --tail 40 leantime"

# The web installer has to be reachable and unfinished, or the account this
# install depends on cannot be created. A miss here is usually LEAN_APP_URL in
# .env disagreeing with the hostname in the Caddy site block.
curl -sS "https://${DOMAIN_HOST}/install" | grep -q 'This script will set up your database' \
	|| die "https://${DOMAIN_HOST}/install did not serve the installer. Compare DOMAIN_NAME in $APP_DIR/.env with the site block in /etc/caddy/Caddyfile."

# --- 7. The first backup, before day one ends --------------------------------
#
# Taken now, while the database is empty, so the restore path is proven before
# it holds anything. Run it again after the account exists.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T leantime_db sh -c 'mysqldump --single-transaction --no-tablespaces -u leantime -p"$MYSQL_PASSWORD" leantime' \
	| gzip > "$APP_DIR/backups/leantime-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/leantime-files-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env userfiles public-userfiles -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/leantime-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	Leantime is answering at https://${DOMAIN_HOST}/healthCheck.php

	  1. Finish the install in a browser. Only you can do this step:
	       open https://${DOMAIN_HOST}/install
	     Fill in your email, first name, last name and company, press Install,
	     then set a password on the Setting Account Details screen that follows.
	     Upstream wants 8 characters with an uppercase, a lowercase, a number
	     and a symbol. Put it in your password manager: nothing on this server
	     can mail it back to you, and this is the only account that exists.
	  2. Then prove the installer closed behind you. Both of these must hold:
	       curl -sS https://${DOMAIN_HOST}/install | grep -c 'This script will set up your database'
	     must print 0, and
	       curl -sSL https://${DOMAIN_HOST}/ | grep -c '<label for="password">Password</label>'
	     must print 1. A 1 from the first means no account was created and the
	     install form is still open on a public hostname. Go back to step 1.
	  3. Your three secrets are in $APP_DIR/.env, mode 600. They were not
	     printed here. LEAN_SESSION_PASSWORD is in that file and nowhere else,
	     so a database dump restored without it signs everybody out.
	  4. First backup written to $APP_DIR/backups: a database dump and a file
	     archive holding compose.yml, .env, both userfiles directories and the
	     Caddy site block. The dump was taken before your account existed, so
	     run the two commands in step 8 of the prompt again tonight. They are on
	     the same disk as the data, which is not a backup. Copy them off the box.

DONE
```

## Also evaluated

Ranked below Leantime for this swap. The prompts above install Leantime only.

- **OpenProject** — Classic project management with work packages, Gantt charts and time tracking, on a server you own and with no per-seat bill. Second place, and first if what you actually need is structure. OpenProject is the catalogue's heavyweight classic answer: work packages with types and statuses, real Gantt, a work breakdown structure, time and cost tracking. It is ranked below Leantime for a monday.com refugee because it asks more of every item you create and reads as project-management software rather than as a board, which is the exact quality that made monday.com spread inside companies in the first place.

The page this mirrors: https://caniselfhostit.com/self-host/monday-com/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
