Time Tracking
    Open Source

    Deploy Kimai on a VPS

    A production-ready, hardened install of the leading open-source time tracker — unlimited users, full data ownership, and a fraction of the cost of per-seat SaaS.

    At a Glance

    ProjectKimai 2.56+
    LicenseAGPLv3
    Recommended PlanRamNode KVM 2 GB (5–25 users); 4 GB+ for active reporting
    OSUbuntu 24.04 LTS
    StackNginx + MariaDB + PHP 8.3-FPM + Composer
    Estimated Setup Time30 minutes

    VPS Sizing

    • 1–5 users: 1 GB RAM / 1 vCPU / 25 GB — workable but tight; tune PHP and MariaDB carefully.
    • 5–25 users: 2 GB RAM / 2 vCPU / 50 GB — comfortable starting point.
    • 25–100 users: 4 GB RAM / 2–4 vCPU / 80 GB — recommended for active reporting.
    • 100+ users: 8 GB+, consider Redis for sessions and a dedicated DB plan.
    1

    Initial Server Hardening

    Update + baseline tools
    apt update && apt upgrade -y
    apt install -y curl wget git unzip ufw vim software-properties-common
    Firewall
    ufw default deny incoming
    ufw default allow outgoing
    ufw allow OpenSSH
    ufw allow 'Nginx Full'
    ufw enable

    If SSH is on a non-standard port, allow it explicitly before enabling UFW or you'll lock yourself out.

    2

    Install the LEMP Stack

    Nginx + MariaDB
    apt install -y nginx
    systemctl enable --now nginx
    
    apt install -y mariadb-server mariadb-client
    systemctl enable --now mariadb
    mariadb-secure-installation
    PHP 8.3 + extensions
    apt install -y php8.3-fpm php8.3-cli php8.3-mysql php8.3-mbstring \
      php8.3-gd php8.3-intl php8.3-xml php8.3-zip php8.3-curl \
      php8.3-bcmath php8.3-opcache php8.3-tokenizer
    
    php -v
    php -m | grep -E 'mbstring|gd|intl|pdo|tokenizer|xml|zip'
    Composer
    curl -sS https://getcomposer.org/installer | php
    mv composer.phar /usr/local/bin/composer
    chmod +x /usr/local/bin/composer
    3

    Tune PHP for Kimai

    Kimai is a Symfony app — a properly sized OPcache is the single biggest performance win on smaller plans.

    /etc/php/8.3/fpm/php.ini
    memory_limit = 256M
    upload_max_filesize = 32M
    post_max_size = 32M
    max_execution_time = 60
    date.timezone = America/New_York
    
    ; OPcache tuning
    opcache.enable = 1
    opcache.memory_consumption = 128
    opcache.interned_strings_buffer = 16
    opcache.max_accelerated_files = 10000
    opcache.validate_timestamps = 1
    opcache.revalidate_freq = 60
    Reload PHP-FPM
    systemctl restart php8.3-fpm

    On a 512 MB plan, drop memory_consumption to 64 and max_accelerated_files to 5000.

    4

    Create the Kimai Database

    MariaDB shell
    mariadb -u root -p
    Create db + user
    CREATE DATABASE kimai CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
    CREATE USER 'kimai'@'localhost' IDENTIFIED BY 'YourStrongPasswordHere';
    GRANT ALL PRIVILEGES ON kimai.* TO 'kimai'@'localhost';
    FLUSH PRIVILEGES;
    EXIT;
    Note the version string for .env
    mariadb --version

    You'll paste the fragment (e.g. 10.11.8-MariaDB) into DATABASE_URL's serverVersion next.

    5

    Dedicated kimai System User

    Better practice than running as www-data — clean ownership, easier backups, smaller blast radius.

    Create user + cross-group membership
    useradd --system --create-home --home-dir /var/www/kimai \
      --shell /bin/bash --user-group kimai
    usermod -aG www-data kimai
    usermod -aG kimai www-data
    6

    Clone and Install Kimai

    Clone latest stable
    sudo -iu kimai
    cd /var/www/kimai
    git clone -b 2.56.0 --depth 1 https://github.com/kimai/kimai.git .
    
    composer install --no-dev --optimize-autoloader

    If Composer crashes with OOM on a 1 GB plan:

    Composer with raised memory limit
    COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=-1 \
      /usr/local/bin/composer install --no-dev --optimize-autoloader
    /var/www/kimai/.env
    APP_ENV=prod
    APP_SECRET=GenerateA32CharRandomStringHere
    
    DATABASE_URL=mysql://kimai:YourStrongPasswordHere@127.0.0.1:3306/kimai?charset=utf8mb4&serverVersion=10.11.8-MariaDB
    
    TRUSTED_HOSTS=^kimai\.yourcompany\.com$
    
    MAILER_URL=smtp://user:pass@smtp.example.com:587?encryption=tls
    MAILER_FROM=kimai@yourcompany.com
    Generate APP_SECRET
    openssl rand -hex 16

    If your DB password contains special chars (@ / #), URL-encode it:

    URL-encode helper
    php -r "echo urlencode('YourActualPassword'), PHP_EOL;"
    Run the installer
    bin/console kimai:install -n
    exit

    ~90% of failed installs are caused by a wrong serverVersion or unencoded password — check those first.

    7

    Set File Permissions

    Symfony-safe permissions
    cd /var/www/kimai
    chown -R kimai:www-data .
    find . -type d -exec chmod 750 {} \;
    find . -type f -exec chmod 640 {} \;
    chmod -R g+rw var/

    If you later see "Permission denied" in var/log/prod.log, this block is the first thing to revisit.

    8

    Configure Nginx

    /etc/nginx/sites-available/kimai
    server {
        listen 80;
        listen [::]:80;
        server_name kimai.yourcompany.com;
    
        root /var/www/kimai/public;
        index index.php;
    
        client_max_body_size 32M;
    
        location / {
            try_files $uri /index.php$is_args$args;
        }
    
        location ~ ^/index\.php(/|$) {
            fastcgi_pass unix:/run/php/php8.3-fpm.sock;
            fastcgi_split_path_info ^(.+\.php)(/.*)$;
            include fastcgi_params;
            fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
            fastcgi_param DOCUMENT_ROOT $realpath_root;
            internal;
        }
    
        location ~ \.php$ { return 404; }
        location ~ /\.(?!well-known) { deny all; }
    
        access_log /var/log/nginx/kimai-access.log;
        error_log  /var/log/nginx/kimai-error.log;
    }
    Enable + reload
    ln -s /etc/nginx/sites-available/kimai /etc/nginx/sites-enabled/
    nginx -t
    systemctl reload nginx
    9

    Let's Encrypt TLS

    Issue + auto-renew
    apt install -y certbot python3-certbot-nginx
    certbot --nginx -d kimai.yourcompany.com
    systemctl list-timers | grep certbot
    certbot renew --dry-run

    Choose to redirect HTTP → HTTPS when prompted.

    10

    Create the First Admin

    CLI super-admin (cleaner than self-promote)
    sudo -iu kimai
    cd /var/www/kimai
    bin/console kimai:user:create admin you@yourcompany.com ROLE_SUPER_ADMIN

    Log in at https://kimai.yourcompany.com and enable 2FA before inviting team members.

    11

    Schedule Recurring Tasks

    crontab -u kimai -e
    * * * * * cd /var/www/kimai && /usr/bin/php bin/console messenger:consume async --time-limit=55 --memory-limit=128M >/dev/null 2>&1
    0 2 * * * cd /var/www/kimai && /usr/bin/php bin/console cache:pool:prune >/dev/null 2>&1

    First line keeps the async messenger consumer alive in 1-minute windows; second prunes expired cache entries nightly.

    12

    Backups

    /usr/local/bin/kimai-backup.sh
    #!/bin/bash
    set -euo pipefail
    
    BACKUP_DIR="/var/backups/kimai"
    TIMESTAMP=$(date +%Y%m%d-%H%M%S)
    DB_USER="kimai"
    DB_PASS="YourStrongPasswordHere"
    DB_NAME="kimai"
    RETENTION_DAYS=14
    
    mkdir -p "$BACKUP_DIR"
    
    mysqldump --single-transaction --quick --lock-tables=false \
      -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" \
      | gzip > "$BACKUP_DIR/kimai-db-$TIMESTAMP.sql.gz"
    
    tar -czf "$BACKUP_DIR/kimai-files-$TIMESTAMP.tar.gz" \
      -C /var/www/kimai .env var/data var/invoices 2>/dev/null || true
    
    find "$BACKUP_DIR" -name "kimai-*.gz" -mtime +$RETENTION_DAYS -delete
    Schedule
    chmod 700 /usr/local/bin/kimai-backup.sh
    chown root:root /usr/local/bin/kimai-backup.sh
    
    crontab -e
    # 30 3 * * * /usr/local/bin/kimai-backup.sh >/var/log/kimai-backup.log 2>&1

    Ship dumps offsite with Restic, Borg, or rclone — a backup on the same disk is not a backup.

    13

    Updating Kimai

    Reliably boring update flow
    sudo -iu kimai
    cd /var/www/kimai
    
    /usr/local/bin/kimai-backup.sh
    bin/console kimai:reload --env=prod
    
    git fetch --tags
    git checkout 2.57.0    # replace with current target
    
    composer install --no-dev --optimize-autoloader
    bin/console kimai:update --env=prod
    bin/console kimai:reload --env=prod

    Read release notes before each upgrade — plugins occasionally need their own bumps.

    Optional next steps

    • Real SMTP: point MAILER_URL at Postmark, SES, or your own Stalwart instance.
    • Plugins: drop into var/plugins/, then bin/console kimai:reload --env=prod.
    • SAML / LDAP: SSO via Google Workspace, Entra, Authentik, or Keycloak.

    Troubleshooting

    • HTTP 500 on first load: tail -n 100 /var/www/kimai/var/log/prod.log
    • "MySQL server has gone away": serverVersion in DATABASE_URL doesn't match mariadb --version
    • "Malformed parameter url": special chars in DB password not URL-encoded.
    • White page after update: bin/console kimai:reload --env=prod
    • Login session drops: verify TRUSTED_HOSTS regex matches your actual domain.