Leantime is a PHP project management system aimed at people who are not project managers. It combines strategy tools (Lean Canvas, SWOT, goal trees) with execution tools (kanban boards, Gantt charts, timesheets, milestones), and it puts real effort into cognitive accessibility.
It is also, from an operator's perspective, the easiest of the three to run. Two containers, MySQL and a PHP app. It fits comfortably on a small RamNode plan. The tradeoffs are elsewhere: a browser-based installer, a config surface spread across environment variables and a legacy PHP config file, and a plugin system that will bite you on upgrade if you skip the volume mounts.
This guide covers a production deployment with Docker Compose, host Nginx for TLS, Redis for sessions, backups, and upgrades.
1. Sizing on RamNode
Leantime is genuinely light. PHP-FPM behind Apache in one container, MySQL 8.4 in another.
| Users | vCPU | RAM | Disk |
|---|---|---|---|
| 1 to 10 | 1 to 2 | 2 GB | 25 GB |
| 10 to 50 | 2 | 4 GB | 40 GB |
| 50+ with heavy file uploads | 2 to 4 | 4 to 8 GB | 80 GB+ |
A 2 GB KVM plan is a real deployment target here, not a compromise. MySQL 8.4 is the memory hog in this stack, not PHP. Section 9 covers tuning it down.
Disk is driven by uploaded files, not the database. Leantime's schema stays small. A team that attaches design mockups to every ticket will fill a 25 GB disk faster than you expect.
Order KVM, not container-based virtualization.
2. Prerequisites
- RamNode KVM VPS running Ubuntu 24.04 LTS
- A non-root sudo user
- A DNS A record for
pm.example.compointing at the VPS - Ports 80 and 443 open
sudo hostnamectl set-hostname leantime
sudo timedatectl set-timezone UTC
sudo apt update && sudo apt -y upgrade
sudo apt -y install git curl ca-certificates ufw nginxSwap
On a 2 GB plan this is not optional. MySQL's InnoDB buffer pool plus a PHP request spike will hit the ceiling.
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
sudo sysctl -w vm.swappiness=10
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.confFirewall
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable3. Install Docker Engine
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/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/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt -y install docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"
newgrp docker
docker compose version4. Clone the Official Compose Repo
sudo mkdir -p /opt/leantime
sudo chown "$USER":"$USER" /opt/leantime
cd /opt
git clone https://github.com/Leantime/docker-leantime.git leantime
cd /opt/leantime
cp sample.env .envLook at what you cloned:
cat docker-compose.ymlTwo services. leantime_db running mysql:8.4 with a db_data volume and a healthcheck. leantime running leantime/leantime:${LEAN_VERSION:-latest}, publishing ${LEAN_PORT:-8080}:8080, with named volumes for public_userfiles, userfiles, plugins, and logs, waiting on the database healthcheck.
The app listens on 8080 inside the container, not 80. Older documentation and Docker Hub examples still say -p 80:80. That is stale. If you copy a compose snippet from a blog post that maps to port 80, you will get connection refused.
Do not remove the plugins volume mount. If you install anything from the Leantime marketplace without that volume, the plugin vanishes on the next container restart. This is the single most common self-inflicted Leantime problem.
5. Configure the Environment
The sample.env file uses spaces around = and quotes values. Docker Compose's env_file parser tolerates this, but be careful when you edit: LEAN_DB_PASSWORD = 'value' and LEAN_DB_PASSWORD='value' do not always behave the same across parsers. Match the existing style in the file.
Generate secrets:
openssl rand -base64 32 | tr -d '/+=' # LEAN_SESSION_PASSWORD
openssl rand -base64 24 | tr -d '/+=' # MYSQL_PASSWORD
openssl rand -base64 24 | tr -d '/+=' # MYSQL_ROOT_PASSWORDnano /opt/leantime/.envSet:
# --- Port binding. Loopback only. Nginx fronts this. ---
LEAN_PORT = '127.0.0.1:8080'
# Pin the version. Do not run latest in production.
LEAN_VERSION = '3.5.9'
# --- MySQL container ---
MYSQL_ROOT_PASSWORD = '<strong-root-password>'
MYSQL_DATABASE = 'leantime'
MYSQL_USER = 'leantime'
MYSQL_PASSWORD = '<strong-db-password>'
# --- Leantime app: must match the MySQL block above ---
LEAN_DB_HOST = 'mysql_leantime'
LEAN_DB_USER = 'leantime'
LEAN_DB_PASSWORD = '<strong-db-password>'
LEAN_DB_DATABASE = 'leantime'
LEAN_DB_PORT = '3306'
# --- Public URL. Required behind a reverse proxy. ---
LEAN_APP_URL = 'https://pm.example.com'
LEAN_APP_DIR = ''
# --- Identity ---
LEAN_SITENAME = 'Example PM'
LEAN_DEFAULT_TIMEZONE = 'America/Chicago'
LEAN_LANGUAGE = 'en-US'
# --- Sessions ---
LEAN_SESSION_PASSWORD = '<output of openssl rand -base64 32>'
LEAN_SESSION_EXPIRATION = 480
LEAN_SESSION_SECURE = true
# --- Debug off in production ---
LEAN_DEBUG = falsechmod 600 /opt/leantime/.envThe details that matter here
LEAN_DB_HOST is mysql_leantime, not leantime_db. The compose file sets container_name: mysql_leantime on the leantime_db service. The app resolves the container name. Using the service name works on the default compose network too, but the shipped sample.env says mysql_leantime and that is what matches the compose file. Do not change one without the other.
LEAN_APP_URL must be set. Without it, Leantime builds absolute URLs from whatever the request looks like, which behind a reverse proxy means http://127.0.0.1:8080. Emailed links and asset URLs will be wrong. No trailing slash.
LEAN_SESSION_SECURE = true requires working HTTPS. Set it now, but if you test over plain HTTP before Nginx is configured, you will not be able to log in. The cookie will not be sent. Either finish TLS first or flip this to false temporarily.
LEAN_SESSION_EXPIRATION is in minutes in Leantime 3.x. The comment in sample.env still says seconds, a leftover from before the Laravel migration. 480 is eight hours. If you literally copy the sample's 28800, you get twenty days of session life, which is almost certainly not what you want.
LEAN_DEBUG exposes stack traces and configuration. Keep it false. Turn it on only while actively debugging, then turn it back off.
Pin LEAN_VERSION. The compose file defaults to latest. Check the current tag at https://hub.docker.com/r/leantime/leantime/tags and pin it. latest means an unattended docker compose pull can walk you across a schema migration you did not plan.
6. First Boot
cd /opt/leantime
docker compose up -d
docker compose psWait for mysql_leantime to report healthy. The app container waits on it via condition: service_healthy, so if the app is stuck in Created, MySQL is still initializing. Give it a minute on first run.
docker compose logs -f leantime
curl -I http://127.0.0.1:8080/A 302 means it is alive and wants you at /install. Do not go there yet. Set up TLS first, because you are about to type an admin password into that form.
Healthcheck warning
Some Leantime image versions ship a healthcheck that reports unhealthy while the container works fine. If docker compose ps shows (unhealthy) but the app responds to curl, this is that bug. Disable the healthcheck in an override rather than fighting it:
services:
leantime:
healthcheck:
disable: true7. Nginx Reverse Proxy and TLS
sudo apt -y install certbot python3-certbot-nginx
sudo systemctl stop nginx
sudo certbot certonly --standalone -d pm.example.com \
--agree-tos -m admin@example.com --no-eff-emailsudo nano /etc/nginx/sites-available/leantimeserver {
listen 80;
listen [::]:80;
server_name pm.example.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name pm.example.com;
ssl_certificate /etc/letsencrypt/live/pm.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pm.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# File uploads to tickets. Match this to PHP's upload_max_filesize.
client_max_body_size 64m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_redirect off;
proxy_read_timeout 180s;
}
}sudo ln -s /etc/nginx/sites-available/leantime /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl start nginxRenewal hook:
sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
echo -e '#!/bin/sh\nsystemctl reload nginx' \
| sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo certbot renew --dry-run8. Run the Installer, Then Lock It Down
Browse to https://pm.example.com/install.
The installer asks for a company name, an admin email, and an admin password. It creates the schema and the first admin user. There is no CLI equivalent, so this browser step is unavoidable.
The install endpoint is unauthenticated until it has been run. Anyone who reaches https://pm.example.com/install before you do owns the instance. The window is small but real. Two ways to close it:
Option A, restrict by IP for the duration. Add to the server block before location /, reload Nginx, run the installer, then remove it:
location /install {
allow 203.0.113.45; # your IP
deny all;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}Option B, do not point DNS at the box until after the install is done, and run the installer through an SSH tunnel:
ssh -L 8080:127.0.0.1:8080 user@vps-ip
# then browse to http://127.0.0.1:8080/install locallyOption A is easier. Option B is airtight. Pick one. Do not skip both.
After install completes, verify /install no longer does anything useful and remove the IP restriction if you added one.
Post-install settings
Log in as the admin you created. Then:
- Company Settings then Users. Confirm only your account exists. Leantime does not seed a default account, so anything else is someone else.
- Turn off self-registration if it is on for your version.
- Set the session timeout to match your policy.
- API keys are created under Company Settings. Only admins can create them by default. Treat them as passwords.
9. Tuning MySQL for a Small Plan
MySQL 8.4's defaults assume a dedicated server. On a 2 GB VPS it will happily take a gigabyte for the buffer pool and then get killed.
Create an override:
nano /opt/leantime/docker-compose.override.ymlservices:
leantime_db:
command: >
--character-set-server=UTF8MB4
--collation-server=UTF8MB4_unicode_ci
--innodb-buffer-pool-size=256M
--innodb-log-file-size=64M
--max-connections=50
--performance-schema=OFF
--table-open-cache=400
deploy:
resources:
limits:
memory: 640M
leantime:
deploy:
resources:
limits:
memory: 768MNote that this command: replaces the one in docker-compose.yml, so the charset flags must be repeated. Drop them and your database silently comes up as latin1, which breaks non-ASCII ticket titles in a way that is annoying to fix later.
--performance-schema=OFF reclaims roughly 100 MB. You lose introspection. On a 2 GB box that is a good trade.
Apply:
cd /opt/leantime
docker compose down && docker compose up -d
docker stats --no-streamRedis for sessions and cache
Optional, but worth it above ten concurrent users. File-based sessions on a small VPS are fine until they are not.
Add to docker-compose.override.yml:
services:
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
networks:
- leantime-net
leantime:
depends_on:
leantime_db:
condition: service_healthyAnd to .env:
LEAN_USE_REDIS = true
LEAN_REDIS_URL = 'tcp://redis:6379'docker compose down && docker compose up -dVerify Leantime is actually using it:
docker compose exec redis redis-cli dbsizeA nonzero and growing count after a few page loads means it works.
10. Outbound Email
RamNode does not permit running mail services on their VPS plans. Leantime needs email for invitations, password resets, and notifications, so point it at an external SMTP relay.
Add to .env:
LEAN_EMAIL_RETURN = 'pm@example.com'
LEAN_EMAIL_USE_SMTP = true
LEAN_EMAIL_SMTP_HOSTS = 'smtp.postmarkapp.com'
LEAN_EMAIL_SMTP_AUTH = true
LEAN_EMAIL_SMTP_USERNAME = '<relay-username>'
LEAN_EMAIL_SMTP_PASSWORD = '<relay-password>'
LEAN_EMAIL_SMTP_AUTO_TLS = true
LEAN_EMAIL_SMTP_SECURE = 'TLS'
LEAN_EMAIL_SMTP_PORT = '587'
LEAN_EMAIL_SMTP_SSLNOVERIFY = falsedocker compose up -dPort and protocol pairing trips people up constantly:
| Port | LEAN_EMAIL_SMTP_SECURE |
|---|---|
| 587 | TLS or STARTTLS |
| 465 | SSL |
| 25 | leave empty |
Microsoft 365 relays want STARTTLS on 587. Most others accept TLS on 587.
If enabling SMTP breaks the app, that is the known failure mode: a misconfigured SMTP block causes errors on any page that tries to send. Set LEAN_DEBUG = true, reproduce, read the error, fix, then set debug back to false.
Never use LEAN_EMAIL_SMTP_SSLNOVERIFY = true against a public relay. It exists for internal relays with self-signed certificates.
11. Backups
sudo nano /usr/local/bin/leantime-backup.sh#!/usr/bin/env bash
set -euo pipefail
STACK=/opt/leantime
DEST=/var/backups/leantime
STAMP=$(date +%F-%H%M)
KEEP_DAYS=14
mkdir -p "$DEST"
cd "$STACK"
# shellcheck disable=SC1091
set -a; . "$STACK/.env"; set +a
# Database
docker compose exec -T leantime_db \
mysqldump -u root -p"${MYSQL_ROOT_PASSWORD//\'/}" \
--single-transaction --routines --triggers "${MYSQL_DATABASE//\'/}" \
| gzip > "$DEST/leantime-db-$STAMP.sql.gz"
# Volumes: user files, public files, plugins
for VOL in userfiles public_userfiles plugins; do
docker run --rm \
-v "leantime_${VOL}:/data:ro" \
-v "$DEST":/backup \
alpine tar -czf "/backup/leantime-${VOL}-$STAMP.tar.gz" -C /data .
done
# Config
cp "$STACK/.env" "$DEST/leantime-env-$STAMP"
if [ -f "$STACK/docker-compose.override.yml" ]; then
cp "$STACK/docker-compose.override.yml" "$DEST/leantime-override-$STAMP.yml"
fi
chmod 600 "$DEST"/leantime-env-*
find "$DEST" -type f -mtime +$KEEP_DAYS -deleteConfirm the volume prefix before trusting it:
docker volume ls | grep -i leantimeCompose prefixes volumes with the directory name. /opt/leantime gives leantime_userfiles. If you cloned into docker-leantime, the prefix is docker-leantime_.
The .env values are quoted, hence the ${VAR//\'/} stripping in the script. Verify the dump is not empty before you rely on it:
sudo chmod +x /usr/local/bin/leantime-backup.sh
sudo /usr/local/bin/leantime-backup.sh
ls -lh /var/backups/leantime/
zcat /var/backups/leantime/leantime-db-*.sql.gz | head -20Schedule:
sudo crontab -e45 2 * * * /usr/local/bin/leantime-backup.sh >> /var/log/leantime-backup.log 2>&1Ship them off the VPS with restic or rclone.
Restore
cd /opt/leantime
docker compose stop leantime
zcat /var/backups/leantime/leantime-db-2026-07-16-0245.sql.gz \
| docker compose exec -T leantime_db mysql -u root -p'<root-password>' leantime
for VOL in userfiles public_userfiles plugins; do
docker run --rm \
-v "leantime_${VOL}:/data" \
-v /var/backups/leantime:/backup:ro \
alpine sh -c "rm -rf /data/* && tar -xzf /backup/leantime-${VOL}-2026-07-16-0245.tar.gz -C /data"
done
docker compose up -d
docker compose exec leantime chown -R www-data:www-data \
/var/www/html/userfiles /var/www/html/public/userfiles \
/var/www/html/storage/logs /var/www/html/app/PluginsRestore .env with the original LEAN_SESSION_PASSWORD or every session breaks.
12. Upgrades
Leantime auto-migrates. After the image changes, the app redirects to /update and applies schema changes when an admin visits.
cd /opt/leantime
sudo /usr/local/bin/leantime-backup.sh
# Bump the pinned version
nano .env # change LEAN_VERSION
docker compose pull
docker compose up -d
docker compose logs -f leantimeThen browse to https://pm.example.com. If migrations are pending you land on /update. Run them. Do not close the tab mid-migration.
Check what you are actually running:
docker compose images
docker inspect --format='{{index .Config.Labels "org.opencontainers.image.version"}}' \
$(docker compose ps -q leantime)If you left LEAN_VERSION at latest, compare the local image date against the tag list on Docker Hub. This is exactly the ambiguity that pinning avoids.
Plugins and upgrades
If the plugins volume is not mounted, marketplace plugins disappear on every image change. Verify before upgrading:
docker compose config | grep -A2 PluginsAfter an upgrade, if plugins fail to load, fix ownership:
docker compose exec leantime chown -R www-data:www-data /var/www/html/app/Plugins
docker compose exec leantime chmod -R 775 /var/www/html/app/PluginsMySQL major upgrades
Do not bump mysql:8.4 to a new major and expect the data directory to migrate. Dump, wipe the volume, restore.
13. Troubleshooting
Connection refused on 8080.
The app listens on 8080 inside the container. If you copied a compose file mapping 80:80, that is the problem.
Login form accepts the password and reloads the login page.
LEAN_SESSION_SECURE = true while you are on plain HTTP, so the cookie is never sent back. Finish TLS or set it to false while testing.
Assets 404, links point at 127.0.0.1:8080.
LEAN_APP_URL is not set, or has a trailing slash. Set it to the exact public origin with no trailing slash.
Container shows (unhealthy) but the app works.
Known image healthcheck bug. Disable it in the override.
Enabling SMTP broke every page.
Misconfigured relay settings. Set LEAN_DEBUG = true, read the actual error, fix the port and security protocol pairing, set debug back to false.
Permission errors writing uploads or logs.
docker compose exec leantime chown -R www-data:www-data \
/var/www/html/userfiles /var/www/html/public/userfiles \
/var/www/html/storage/logs /var/www/html/app/Plugins
docker compose exec leantime chmod -R 775 \
/var/www/html/userfiles /var/www/html/public/userfiles \
/var/www/html/storage/logs /var/www/html/app/PluginsOr set PUID and PGID in .env to match a host user if you switch to bind mounts.
Locked out of the admin account with no working email. Reset the hash directly. Generate a bcrypt hash first:
docker compose exec leantime php -r \
'echo password_hash("NewStrongPassword1!", PASSWORD_BCRYPT), PHP_EOL;'Then:
docker compose exec leantime_db mysql -u root -p'<root-password>' leantime \
-e "UPDATE zp_user SET password='<hash>' WHERE username='admin@example.com';"Uploads fail at some size.
Both client_max_body_size in Nginx and PHP's upload_max_filesize and post_max_size must allow it. Nginx returns 413. PHP fails more quietly.
Non-ASCII characters garbled in ticket titles.
The database came up without UTF8MB4 because a command: override dropped the charset flags. Section 9 warns about this. Fixing it after data exists means a dump, a charset conversion, and a restore.
Plugins vanish after restart.
The plugins volume is not mounted.
14. Where Leantime Fits
Leantime's pitch is real: it is the only tool in this category that leads with strategy artifacts (Lean Canvas, SWOT, goal trees) rather than a backlog, and its accessibility work is not marketing copy. If your users are founders, marketers, or researchers rather than engineers, it lands better than Jira-shaped tools.
It is the lightest of the three to operate by a wide margin. Two containers, a 2 GB plan, and a schema that stays small.
The costs are the browser installer, a configuration surface split across .env and a legacy PHP config file that overlap in confusing ways, and a plugin model where the useful pieces live in a paid marketplace.
Choose Taiga instead if your team runs real sprints and wants Scrum vocabulary. Choose OpenProject if you need Gantt with dependency scheduling or cost tracking, and budget four times the RAM for the privilege.
Reference
- Leantime documentation: https://docs.leantime.io/
- Docker installation: https://docs.leantime.io/installation/docker
- Compose repository: https://github.com/Leantime/docker-leantime
- Image tags: https://hub.docker.com/r/leantime/leantime/tags
