Kimai is a mature, open-source time-tracking platform used by freelancers, agencies, and enterprises to log billable hours, generate invoices, and analyse project profitability. Running it on your own server means no per-seat pricing, no data leaving your network, and full control over your retention and export workflows. This guide walks through a production-grade deployment on Ubuntu 22.04 using Docker Compose, Caddy as the TLS-terminating reverse proxy, and MariaDB as the persistent database backend.
Architecture and flow overview
The stack consists of three containers managed by a single Docker Compose file:
- kimai — the PHP/Symfony application container, exposed on an internal port only.
- mariadb — the relational database storing all time entries, users, projects, and activities.
- caddy — the reverse proxy that handles HTTPS certificate issuance via Let's Encrypt ACME and forwards HTTP/S traffic to the Kimai container.
Caddy listens on ports 80 and 443 on the host. All three containers share a private kimai_net Docker bridge network. MariaDB is never exposed on the host network. Kimai talks to MariaDB over the internal network using a dedicated application user with least-privilege grants. Persistent data lives in named Docker volumes (kimai_data, kimai_plugins, mariadb_data) so container restarts are non-destructive.
Prerequisites
- Ubuntu 22.04 VPS with a public IPv4 (or IPv6) address
- A domain name with an
Arecord pointing to the server (e.g.,kimai.example.com) - Docker Engine 24+ and Docker Compose v2 installed (
docker compose version) - Ports 80 and 443 open in the host firewall (
ufw allow 80,443/tcp) - At least 1 GB RAM and 10 GB disk space
Step-by-step deployment
1) Create the project directory
mkdir -p /opt/kimai
cd /opt/kimai2) Create the secrets file
Store all credentials in a .env file. Never commit this file to version control.
cat > /opt/kimai/.env << 'EOF'
MARIADB_ROOT_PASSWORD=changeme_root_strong
MARIADB_DATABASE=kimai
MARIADB_USER=kimai
MARIADB_PASSWORD=changeme_kimai_strong
KIMAI_APP_SECRET=changeme_app_secret_32chars_min_here
KIMAI_DOMAIN=kimai.example.com
[email protected]
KIMAI_ADMIN_PASSWORD=changeme_admin_strong
EOF
chmod 600 /opt/kimai/.env3) Write the Docker Compose file
version: "3.9"
services:
mariadb:
image: mariadb:10.11
restart: unless-stopped
env_file: .env
environment:
MYSQL_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MYSQL_DATABASE: ${MARIADB_DATABASE}
MYSQL_USER: ${MARIADB_USER}
MYSQL_PASSWORD: ${MARIADB_PASSWORD}
volumes:
- mariadb_data:/var/lib/mysql
networks:
- kimai_net
kimai:
image: kimai/kimai2:apache
restart: unless-stopped
env_file: .env
environment:
APP_ENV: prod
APP_SECRET: ${KIMAI_APP_SECRET}
DATABASE_URL: mysql://${MARIADB_USER}:${MARIADB_PASSWORD}@mariadb:3306/${MARIADB_DATABASE}?charset=utf8mb4&serverVersion=10.11.0-MariaDB
TRUSTED_PROXIES: 172.16.0.0/12,192.168.0.0/16,10.0.0.0/8
TRUSTED_HOSTS: ${KIMAI_DOMAIN}
ADMINMAIL: ${KIMAI_ADMIN_EMAIL}
ADMINPASS: ${KIMAI_ADMIN_PASSWORD}
volumes:
- kimai_data:/opt/kimai/var/data
- kimai_plugins:/opt/kimai/var/plugins
depends_on:
- mariadb
networks:
- kimai_net
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- kimai_net
volumes:
mariadb_data:
kimai_data:
kimai_plugins:
caddy_data:
caddy_config:
networks:
kimai_net:
driver: bridge4) Configure Caddy
kimai.example.com {
reverse_proxy kimai:8001
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
X-Frame-Options DENY
}
}Replace kimai.example.com with your actual domain. Caddy automatically obtains and renews the Let's Encrypt certificate.
5) Start the stack
docker compose up -d
# Watch logs until Kimai finishes initialising (first start runs DB migrations)
docker compose logs -f kimaiFirst startup takes 60–90 seconds while Symfony runs Doctrine migrations. The container is ready when you see AH00558: apache2: Could not reliably determine in the logs, which is a cosmetic Apache warning, not an error.
Configuration and secrets handling
All sensitive values live in /opt/kimai/.env with mode 600. Docker Compose reads the file via env_file, so secrets are never baked into the image or the Compose YAML. For production environments where you want secret rotation without restarting the container, consider mounting a secrets directory or integrating with Vault/Infisical and injecting via entrypoint scripts.
Key configuration tips:
APP_SECRETmust be at least 32 random characters. Generate with:openssl rand -hex 32- Set
TRUSTED_PROXIESto include Docker's bridge network ranges; without this, Kimai cannot resolve real client IPs behind Caddy. TRUSTED_HOSTSmust match your domain exactly; Symfony rejects requests with mismatchedHostheaders.- Do not use the default admin credentials in production — change them immediately after first login or provision them via the env vars above.
To create additional admin users after deployment:
docker compose exec kimai bin/console kimai:user:create \
username [email protected] ROLE_SUPER_ADMIN 'StrongPassword!'Verification
After the stack is up, verify each layer in order:
- Check all containers are healthy:
docker compose ps— all three should showrunning. - Test the MariaDB connection from within Kimai:
docker compose exec kimai bin/console doctrine:migrations:status— should show all migrations applied. - Confirm HTTPS: open
https://kimai.example.comin a browser and verify the padlock and a valid Let's Encrypt certificate. - Log in with the admin credentials from
.envand create a test project and activity. - Verify Caddy security headers with:
curl -I https://kimai.example.com— you should seeStrict-Transport-SecurityandX-Frame-Options.
Common issues and fixes
- Kimai shows a blank page or 500 error on first load
- MariaDB may still be initialising. Run
docker compose logs mariadband wait forready for connections. Then restart Kimai:docker compose restart kimai. - Caddy cannot obtain a certificate (port 80 blocked)
- Ensure port 80 is open:
ufw allow 80/tcp. Let's Encrypt ACME HTTP-01 challenge requires port 80 to be publicly reachable. Check that no other process is binding port 80:ss -tlnp | grep ':80'. - Database connection refused error in Kimai logs
- The
DATABASE_URLhostname must match the Docker Compose service name (mariadb). Check the.envvalues and confirm the MariaDB container is healthy before Kimai starts. Add adepends_on: condition: service_healthyblock with a healthcheck onmariadbif timing issues persist. - CSRF token mismatch on login
- This usually means the
APP_SECRETvalue changed between requests (e.g., container restart with a new random secret). EnsureAPP_SECRETis pinned in.envand not regenerated on each start. - Uploaded files (invoices, exports) are lost after container restart
- Verify the
kimai_datavolume is correctly mapped. Rundocker volume inspect kimai_kimai_datato confirm it exists and has non-zero size.
FAQ
What Kimai Docker image should I use — apache or fpm?
The kimai2:apache image bundles Apache and is the simplest production option — no separate PHP-FPM + Nginx configuration needed. Use kimai2:fpm only if you need deeper control over PHP process management or are integrating with an existing Nginx setup where FPM is preferred.
Can I migrate from the hosted Kimai Cloud to self-hosted?
Yes. Export your data from Kimai Cloud as a JSON or CSV bundle, then use the Kimai import plugin or the CLI importer (bin/console kimai:import) on your self-hosted instance. For large datasets, run the import inside the container with increased PHP memory limit: set KIMAI_PHP_MEMORY_LIMIT=512M in your .env.
How do I enable LDAP or SAML authentication?
Kimai supports LDAP via the kimai-ldap plugin and SAML via kimai-saml. Install plugins by dropping them into the kimai_plugins volume at /opt/kimai/var/plugins/, then run docker compose exec kimai bin/console kimai:reload. Configure LDAP host, bind DN, and filter in config/packages/security.yaml or via environment variables documented in the plugin README.
How do I back up and restore Kimai?
Back up both the MariaDB data and the Kimai data volume. A minimal daily backup script:
docker compose exec -T mariadb mysqldump -u kimai -p"${MARIADB_PASSWORD}" kimai \
| gzip > /backups/kimai_db_$(date +%Y%m%d).sql.gz
docker run --rm -v kimai_kimai_data:/data -v /backups:/backup \
alpine tar czf /backup/kimai_data_$(date +%Y%m%d).tar.gz /dataTo restore, stop the stack, import the SQL dump with docker compose exec -T mariadb mysql -u kimai -p kimai, and extract the data archive back into the volume.
How do I upgrade Kimai to a newer version?
Update the image tag in docker-compose.yml (or use latest for tracking releases), pull, and restart:
docker compose pull kimai
docker compose up -d kimai
docker compose exec kimai bin/console kimai:updateThe kimai:update command runs cache clearing, database migrations, and reloads plugins. Always check the Kimai changelog for breaking changes before upgrading across major versions.
Can multiple users log time simultaneously without conflicts?
Yes. Kimai is a multi-user, multi-tenant application designed for concurrent access. User-level locking occurs per timesheet entry, not at the application level. For high concurrency (50+ simultaneous users), increase MariaDB's max_connections and Kimai's PHP-FPM worker count or use the apache image with increased MaxRequestWorkers via an Apache config override volume.
How do I restrict which projects a user can see and log time on?
Kimai has granular role and permission management. Assign users to Teams and associate teams with specific projects and customers. Team members see only the projects assigned to their team. Super-admins have full visibility. Configure under Administration → Teams in the Kimai web UI.
Internal links
- Production Guide: Deploy GitLab CE with Docker Compose + Caddy + PostgreSQL + Redis on Ubuntu
- Production Guide: Deploy Jenkins with Docker Compose + Caddy on Ubuntu
- Deploy Woodpecker CI with Docker Compose and Caddy on Ubuntu (Production Guide)
Talk to us
If you want this deployed with hardened access controls, monitoring standards, and production runbooks tailored to your environment, our team can help end-to-end.