Project Management
    Docker Compose

    Deploy Leantime on a VPS

    Deploy Leantime — a lightweight PHP project management system with Lean Canvas, SWOT, kanban, Gantt, and timesheets — on a RamNode KVM VPS with Docker Compose and Nginx TLS.

    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.

    UsersvCPURAMDisk
    1 to 101 to 22 GB25 GB
    10 to 5024 GB40 GB
    50+ with heavy file uploads2 to 44 to 8 GB80 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.com pointing at the VPS
    • Ports 80 and 443 open
    shell
    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 nginx

    Swap

    On a 2 GB plan this is not optional. MySQL's InnoDB buffer pool plus a PHP request spike will hit the ceiling.

    shell
    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.conf

    Firewall

    shell
    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 enable

    3. Install Docker Engine

    shell
    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 version

    4. Clone the Official Compose Repo

    shell
    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 .env

    Look at what you cloned:

    shell
    cat docker-compose.yml

    Two 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:

    shell
    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_PASSWORD
    shell
    nano /opt/leantime/.env

    Set:

    shell
    # --- 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 = false
    shell
    chmod 600 /opt/leantime/.env

    The 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

    shell
    cd /opt/leantime
    docker compose up -d
    docker compose ps

    Wait 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.

    shell
    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:

    shell
    services:
      leantime:
        healthcheck:
          disable: true

    7. Nginx Reverse Proxy and TLS

    shell
    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-email
    shell
    sudo nano /etc/nginx/sites-available/leantime
    shell
    server {
        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;
        }
    }
    shell
    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 nginx

    Renewal hook:

    shell
    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-run

    8. 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:

    shell
        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:

    shell
    ssh -L 8080:127.0.0.1:8080 user@vps-ip
    # then browse to http://127.0.0.1:8080/install locally

    Option 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:

    shell
    nano /opt/leantime/docker-compose.override.yml
    shell
    services:
      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: 768M

    Note 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:

    shell
    cd /opt/leantime
    docker compose down && docker compose up -d
    docker stats --no-stream

    Redis 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:

    shell
    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_healthy

    And to .env:

    shell
    LEAN_USE_REDIS = true
    LEAN_REDIS_URL = 'tcp://redis:6379'
    shell
    docker compose down && docker compose up -d

    Verify Leantime is actually using it:

    shell
    docker compose exec redis redis-cli dbsize

    A 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:

    shell
    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 = false
    shell
    docker compose up -d

    Port and protocol pairing trips people up constantly:

    PortLEAN_EMAIL_SMTP_SECURE
    587TLS or STARTTLS
    465SSL
    25leave 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

    shell
    sudo nano /usr/local/bin/leantime-backup.sh
    shell
    #!/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 -delete

    Confirm the volume prefix before trusting it:

    shell
    docker volume ls | grep -i leantime

    Compose 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:

    shell
    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 -20

    Schedule:

    shell
    sudo crontab -e
    shell
    45 2 * * * /usr/local/bin/leantime-backup.sh >> /var/log/leantime-backup.log 2>&1

    Ship them off the VPS with restic or rclone.

    Restore

    shell
    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/Plugins

    Restore .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.

    shell
    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 leantime

    Then 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:

    shell
    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:

    shell
    docker compose config | grep -A2 Plugins

    After an upgrade, if plugins fail to load, fix ownership:

    shell
    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/Plugins

    MySQL 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.

    shell
    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/Plugins

    Or 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:

    shell
    docker compose exec leantime php -r \
      'echo password_hash("NewStrongPassword1!", PASSWORD_BCRYPT), PHP_EOL;'

    Then:

    shell
    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