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
| Project | Kimai 2.56+ |
| License | AGPLv3 |
| Recommended Plan | RamNode KVM 2 GB (5–25 users); 4 GB+ for active reporting |
| OS | Ubuntu 24.04 LTS |
| Stack | Nginx + MariaDB + PHP 8.3-FPM + Composer |
| Estimated Setup Time | 30 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.
Initial Server Hardening
apt update && apt upgrade -y
apt install -y curl wget git unzip ufw vim software-properties-commonufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enableIf SSH is on a non-standard port, allow it explicitly before enabling UFW or you'll lock yourself out.
Install the LEMP Stack
apt install -y nginx
systemctl enable --now nginx
apt install -y mariadb-server mariadb-client
systemctl enable --now mariadb
mariadb-secure-installationapt 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'curl -sS https://getcomposer.org/installer | php
mv composer.phar /usr/local/bin/composer
chmod +x /usr/local/bin/composerTune PHP for Kimai
Kimai is a Symfony app — a properly sized OPcache is the single biggest performance win on smaller plans.
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 = 60systemctl restart php8.3-fpmOn a 512 MB plan, drop memory_consumption to 64 and max_accelerated_files to 5000.
Create the Kimai Database
mariadb -u root -pCREATE 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;mariadb --versionYou'll paste the fragment (e.g. 10.11.8-MariaDB) into DATABASE_URL's serverVersion next.
Dedicated kimai System User
Better practice than running as www-data — clean ownership, easier backups, smaller blast radius.
useradd --system --create-home --home-dir /var/www/kimai \
--shell /bin/bash --user-group kimai
usermod -aG www-data kimai
usermod -aG kimai www-dataClone and Install Kimai
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-autoloaderIf Composer crashes with OOM on a 1 GB plan:
COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=-1 \
/usr/local/bin/composer install --no-dev --optimize-autoloaderAPP_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.comopenssl rand -hex 16If your DB password contains special chars (@ / #), URL-encode it:
php -r "echo urlencode('YourActualPassword'), PHP_EOL;"bin/console kimai:install -n
exit~90% of failed installs are caused by a wrong serverVersion or unencoded password — check those first.
Set File 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.
Configure Nginx
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;
}ln -s /etc/nginx/sites-available/kimai /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginxLet's Encrypt TLS
apt install -y certbot python3-certbot-nginx
certbot --nginx -d kimai.yourcompany.com
systemctl list-timers | grep certbot
certbot renew --dry-runChoose to redirect HTTP → HTTPS when prompted.
Create the First Admin
sudo -iu kimai
cd /var/www/kimai
bin/console kimai:user:create admin you@yourcompany.com ROLE_SUPER_ADMINLog in at https://kimai.yourcompany.com and enable 2FA before inviting team members.
Schedule Recurring Tasks
* * * * * 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>&1First line keeps the async messenger consumer alive in 1-minute windows; second prunes expired cache entries nightly.
Backups
#!/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 -deletechmod 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>&1Ship dumps offsite with Restic, Borg, or rclone — a backup on the same disk is not a backup.
Updating Kimai
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=prodRead release notes before each upgrade — plugins occasionally need their own bumps.
Optional next steps
- Real SMTP: point
MAILER_URLat Postmark, SES, or your own Stalwart instance. - Plugins: drop into
var/plugins/, thenbin/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":
serverVersioninDATABASE_URLdoesn't matchmariadb --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_HOSTSregex matches your actual domain.
