Skip to Content

Production Guide: Deploy Kimai with Docker Compose + Caddy + MariaDB on Ubuntu

Self-host the leading open-source time tracker with HTTPS, MariaDB persistence, and production hardening

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 A record 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/kimai

2) 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/.env

3) 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: bridge

4) 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 kimai

First 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_SECRET must be at least 32 random characters. Generate with: openssl rand -hex 32
  • Set TRUSTED_PROXIES to include Docker's bridge network ranges; without this, Kimai cannot resolve real client IPs behind Caddy.
  • TRUSTED_HOSTS must match your domain exactly; Symfony rejects requests with mismatched Host headers.
  • 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:

  1. Check all containers are healthy: docker compose ps — all three should show running.
  2. Test the MariaDB connection from within Kimai: docker compose exec kimai bin/console doctrine:migrations:status — should show all migrations applied.
  3. Confirm HTTPS: open https://kimai.example.com in a browser and verify the padlock and a valid Let's Encrypt certificate.
  4. Log in with the admin credentials from .env and create a test project and activity.
  5. Verify Caddy security headers with: curl -I https://kimai.example.com — you should see Strict-Transport-Security and X-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 mariadb and wait for ready 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_URL hostname must match the Docker Compose service name (mariadb). Check the .env values and confirm the MariaDB container is healthy before Kimai starts. Add a depends_on: condition: service_healthy block with a healthcheck on mariadb if timing issues persist.
CSRF token mismatch on login
This usually means the APP_SECRET value changed between requests (e.g., container restart with a new random secret). Ensure APP_SECRET is pinned in .env and not regenerated on each start.
Uploaded files (invoices, exports) are lost after container restart
Verify the kimai_data volume is correctly mapped. Run docker volume inspect kimai_kimai_data to 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 /data

To 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:update

The 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

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.

Contact Us

Deploy Woodpecker CI with Docker Compose and Caddy on Ubuntu (Production Guide)
A practical, production-ready deployment of Woodpecker CI using Docker Compose, Caddy, and PostgreSQL on Ubuntu.