What Kimai needs from the machine
Kimai is a PHP application on the Symfony framework with a MariaDB or MySQL database. The installation page asks for PHP 8.2+, nginx or Apache with mod_rewrite, and a subdomain of its own: a subdirectory is not supported. The Docker image carries PHP and Apache, so the host needs Docker Engine with the Compose plugin and a TLS proxy. The docs publish no CPU or memory figure per user; size the disk for the database, its dumps and the local backup copies.
| Piece | Where it runs | Host path (our layout) |
|---|---|---|
| Kimai, image kimai/kimai2 | container kimai, 127.0.0.1:8001 | /opt/kimai/data, /opt/kimai/plugins |
| MySQL 8.3 | container sqldb, no published port | /opt/kimai/mysql |
| Caddy | host package, ports 80 and 443 | /etc/caddy/Caddyfile |
| Secrets | read by Compose | /opt/kimai/.env, mode 600 |
Choosing the server
Billable hours and invoices are business records. On a dedicated server they sit on disks no other tenant touches, under your root account, on our own network, AS19624, which anyone can verify at bgp.tools.
Two classes from the dedicated server range fit: a Moonshot cartridge, one service per machine, or a single-socket rack server, the range's base for an application with its database on the same box. The cartridges have no RAID controller and most carry one disk; the rack and blade lines have a hardware controller. Every racked single-, dual- and quad-socket server has an out-of-band console (iLO, iDRAC or a Supermicro BMC), as do the m510 and m710x cartridges; other cartridges have power control only. That console is the way back in after a bad ufw rule drops your SSH session.
One exclusion: the X-Gene ARM cartridge. Its installer list stops at Ubuntu 18.04, with 16.04 as the other choice, older than the 24.04 used below.
Delivery: about 30 minutes for a racked machine once payment is approved; see ready servers. A build to your own specification takes 4 to 24 hours.
Setting it up
- Install Ubuntu 24.04 through the control panel. Docker's own install guide covers the Engine plus the Compose plugin; follow it.
- Point an A record for kimai.example.com at the server (and AAAA if you added IPv6 at checkout). Continue only when
getent ahosts kimai.example.comreturns the server's addresses: Caddy will request the certificate the moment it loads the site, later on, so the name must already resolve before that step. - As root, paste the block that follows. Its order: ufw's IPv6 switch, the OpenSSH rule, ufw on, ports 80 and 443, directories, the secret file. Caddy waits until the admin account is safe. Put your name in the TRUSTED_HOSTS line but keep localhost and 127.0.0.1 in it: the image's HEALTHCHECK (Dockerfile) runs
curl -f http://127.0.0.1:8001inside the container.
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw --force enable
ufw allow 80,443/tcp
install -d -m 700 /opt/kimai /opt/kimai/data /opt/kimai/plugins /opt/kimai/mysql
install -m 600 /dev/null /opt/kimai/.env
cat > /opt/kimai/.env <<EOF
DATABASE_NAME=kimai
DATABASE_USER=kimai
DATABASE_PASSWORD=$(openssl rand -hex 24)
DATABASE_ROOT_PASSWORD=$(openssl rand -hex 24)
APP_SECRET=$(openssl rand -hex 32)
TRUSTED_HOSTS=^(localhost|127\.0\.0\.1|kimai\.example\.com)$
EOF
The .env file exists at mode 600 before a secret is written to it. Passwords are hex because DATABASE_URL breaks on characters such as @ or / unless URL-encoded (installation FAQ).
Save this as /opt/kimai/compose.yaml. It is the docs' .env-based example with our changes: bind mounts instead of named volumes, an exact image release, the port on 127.0.0.1, TRUSTED_PROXIES added, and no ADMINMAIL/ADMINPASS.
# /opt/kimai/compose.yaml
services:
sqldb:
image: mysql:8.3
volumes:
- /opt/kimai/mysql:/var/lib/mysql
environment:
- MYSQL_DATABASE=${DATABASE_NAME}
- MYSQL_USER=${DATABASE_USER}
- MYSQL_PASSWORD=${DATABASE_PASSWORD}
- MYSQL_ROOT_PASSWORD=${DATABASE_ROOT_PASSWORD}
command: --default-storage-engine innodb
restart: unless-stopped
healthcheck:
test: mysqladmin -p$$MYSQL_ROOT_PASSWORD ping -h localhost
interval: 20s
start_period: 10s
timeout: 10s
retries: 3
kimai:
image: kimai/kimai2:2.70.2
depends_on:
sqldb:
condition: service_healthy
restart: true
volumes:
- /opt/kimai/data:/opt/kimai/var/data
- /opt/kimai/plugins:/opt/kimai/var/plugins
ports:
- 127.0.0.1:8001:8001
environment:
- APP_SECRET=${APP_SECRET}
- TRUSTED_HOSTS=${TRUSTED_HOSTS}
- TRUSTED_PROXIES=127.0.0.1,PRIVATE_SUBNETS
- "DATABASE_URL=mysql://${DATABASE_USER}:${DATABASE_PASSWORD}@sqldb/${DATABASE_NAME}?charset=utf8mb4&serverVersion=8.3.0"
restart: unless-stopped
Start it with docker compose -f /opt/kimai/compose.yaml up -d and check docker compose -f /opt/kimai/compose.yaml ps. A port mapping with no address is published on 0.0.0.0 and [::], and that traffic skips ufw's rules on IPv4 (on IPv6 too when Docker's own IPv6 networking is on), so the docs' 8001:8001 would expose plain HTTP. On Docker Engine 28 and later a 127.0.0.1 binding can be reached from the host alone. The healthcheck puts the MySQL root password on a command line inside the container, visible to ps while it runs.
The first admin, and who can sign up
While Kimai still answers only on 127.0.0.1, create the administrator from the console: docker compose -f /opt/kimai/compose.yaml exec kimai /opt/kimai/bin/console kimai:user:create yourname you@example.com ROLE_SUPER_ADMIN. Username and email are required; without a role the user gets ROLE_USER. With no password argument it asks one question, Please enter the password:, input hidden, empty answers refused, three attempts (from CreateUserCommand.php and AbstractUserCommand.php in the source). Let it prompt: a password typed as an argument lands in shell history.
The image's alternative is ADMINMAIL and ADMINPASS in the kimai service's environment, which create the admin "if not existing" (image docs). Per .docker/entrypoint.sh the account is named admin, and on every container start the script passes ADMINPASS as an argument to kimai:user:create, so ps shows it while that runs; it also stays in .env and in docker inspect output. On that route, replace the password before Caddy makes the site public: docker compose -f /opt/kimai/compose.yaml exec kimai /opt/kimai/bin/console kimai:user:password admin prompts for a new one, or sign in over an SSH tunnel of your own, ssh -L 18001:127.0.0.1:8001 you@server.example, at http://localhost:18001 (TRUSTED_HOSTS above already admits localhost). Then delete both lines from compose.yaml and .env and run docker compose -f /opt/kimai/compose.yaml up -d so the container is recreated without them.
Self-registration is off by default: src/DependencyInjection/Configuration.php sets user.registration to false, and src/Controller/Security/SelfRegistrationController.php answers not found while it stays off. The installation guide says the first user to register is promoted to ROLE_SUPER_ADMIN, so never enable it before your admin exists.
Going public: Caddy, TRUSTED_HOSTS, TRUSTED_PROXIES
With the admin password settled, install the proxy: apt-get update && apt-get install -y caddy, then printf 'kimai.example.com {\n reverse_proxy 127.0.0.1:8001 {\n flush_interval -1\n }\n}\n' > /etc/caddy/Caddyfile and systemctl reload caddy. Caddy then obtains its Let's Encrypt certificate unaided. TRUSTED_HOSTS is a regular expression of the names used to reach Kimai, and must be set. Behind a TLS proxy, TRUSTED_PROXIES tells Kimai to read the X-Forwarded-* headers; without it you may be redirected to plain http URLs. It accepts IPs, CIDR ranges and Symfony keywords, not host names; the image default is 127.0.0.1.
Our reasoning: through a 127.0.0.1 port mapping, Caddy's requests reach the container from Docker's bridge, a private address, so we use the docs' example value 127.0.0.1,PRIVATE_SUBNETS. Caddy's reverse_proxy sets X-Forwarded-For, -Proto and -Host by default (Caddy docs); flush_interval -1 is from Kimai's Caddy example.
Plugins from the Kimai Store
Plugins are listed in the Store on kimai.org; licensed ones download from Kimai's License-Manager. Each is a bundle directory under var/plugins, here /opt/kimai/plugins.
- Unpack it so /opt/kimai/plugins/FooBarBundle/FooBarBundle.php exists; rename a FooBarBundle-1.0 directory.
docker compose -f /opt/kimai/compose.yaml restart kimai: per .docker/entrypoint.sh it reloads the cache, then hands var/ to the web-server user.- If it has database tables, take the exact install command from the plugin\'s own docs; plugin management gives the pattern
kimai:bundle:NAME:install, and the Docker Compose page uses workcontract as its example. Run it throughdocker compose -f /opt/kimai/compose.yaml exec kimai /opt/kimai/bin/console, then restart again (our habit, to fix ownership).
A .disabled file switches a plugin off; deleting its directory removes it. The docs blame a 500 error after installing on a version mismatch, permissions, or a skipped install command.
Backups that hold everything
The backup page lists the database plus .env, local.yaml, var/data, var/export, var/invoices, var/packages and var/plugins. The compose setup keeps only var/data and var/plugins outside the container; if you add a local.yaml or your own templates, mount them from /opt/kimai (our advice). The script dumps MySQL and archives the rest of /opt/kimai.
# /opt/kimai/backup.sh -- run as root: bash /opt/kimai/backup.sh
set -euo pipefail
umask 077
B=/var/backups/kimai
D=$(date +%F-%H%M)
mkdir -p -m 700 "$B"
docker compose -f /opt/kimai/compose.yaml exec -T sqldb sh -c \
'exec mysqldump --single-transaction --no-tablespaces -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
| gzip > "$B/kimai-db-$D.sql.gz.part"
mv "$B/kimai-db-$D.sql.gz.part" "$B/kimai-db-$D.sql.gz"
tar -czf "$B/kimai-files-$D.tar.gz.part" --exclude=./mysql -C /opt/kimai .
mv "$B/kimai-files-$D.tar.gz.part" "$B/kimai-files-$D.tar.gz"
find "$B" -name 'kimai-*' -mtime +30 -delete
pipefail stops a failed dump leaving a valid-looking gzip; .part names keep an interrupted run from looking finished. The dump's password is on a command line in the container while it runs. 30 days of retention is our choice. Schedule it: install -d -m 700 /var/log/kimai-backup, then printf '15 2 * * * root umask 077; bash /opt/kimai/backup.sh > /var/log/kimai-backup/last.log 2>&1\n' > /etc/cron.d/kimai-backup. Copy /var/backups/kimai to another machine yourself, with rsync or scp; a backup on the same disks fails with them.
Restoring over a live install
Kimai's rule is to restore into the version that made the backup. The archive carries compose.yaml with its pinned release, so that comes back too. STAMP is the backup's timestamp, for example 2026-10-23-0215.
gzip -t /var/backups/kimai/kimai-db-STAMP.sql.gz && tar -tzf /var/backups/kimai/kimai-files-STAMP.tar.gz > /dev/null && echo okmust print ok.docker compose -f /opt/kimai/compose.yaml downtar -czf /root/kimai-before-restore.tar.gz -C /opt kimai && tar -tzf /root/kimai-before-restore.tar.gz > /dev/null && echo ok. Stop here unless it prints ok.mv /opt/kimai /opt/kimai.old && install -d -m 700 /opt/kimai && tar -xzf /var/backups/kimai/kimai-files-STAMP.tar.gz -C /opt/kimai && install -d -m 700 /opt/kimai/mysql. The old .env is back before MySQL first starts; the image creates its user and database from it only on an empty directory.docker compose -f /opt/kimai/compose.yaml up -d sqldb, then wait fordocker compose -f /opt/kimai/compose.yaml psto show healthy.gunzip -c /var/backups/kimai/kimai-db-STAMP.sql.gz > /root/kimai-restore.sqlthendocker compose -f /opt/kimai/compose.yaml exec -T sqldb sh -c 'exec mysql --user="$MYSQL_USER" --password="$MYSQL_PASSWORD" --database="$MYSQL_DATABASE"' < /root/kimai-restore.sql && echo importeddocker compose -f /opt/kimai/compose.yaml up -d. The start-up script (.docker/entrypoint.sh) resets ownership of var/. Log in and check the latest timesheets.- Then
rm -rf /opt/kimai.old /root/kimai-restore.sql; keep the before-restore archive until you are sure.
Updates and the licence
The update page wants a working database backup and a read of UPGRADING first; several releases can be skipped at once. A git install checks out the new tag, reinstalls the Composer dependencies and runs bin/console kimai:update. With Docker: back up, change the release on the image line, then docker compose -f /opt/kimai/compose.yaml pull, docker compose -f /opt/kimai/compose.yaml down, docker compose -f /opt/kimai/compose.yaml up -d. In src/Command/InstallCommand.php, kimai:update is an alias of kimai:install, which runs the migrations, and .docker/entrypoint.sh runs kimai:install -n at every start. The docs advise against a fixed release since it never updates itself; we pin one so a restore matches its dump.
Kimai is AGPL-3.0, a copyleft licence. Our general reading, not legal advice: running it yourself, unmodified, carries no duty. Hand copies to others, modified or not, and the licence and the source go with them. Serve a modified build to users over a network, and those users are owed its source. Store plugins carry their own terms.
Questions
Is the Kimai Docker image enough on its own?
No. It holds Kimai with Apache and PHP but needs a MariaDB or MySQL database, which the compose example runs as a second container. The docs' quick test is not for production: its data goes when the containers are removed.
Can I use MariaDB instead of MySQL?
Yes. The serverVersion in DATABASE_URL must then carry the exact MariaDB version string, for example 11.1.2-MariaDB; the installation FAQ links a wrong value to migration errors.
Can Kimai live at example.com/kimai?
No. It needs a subdomain of its own; a subdirectory is not supported. Use a name such as kimai.example.com.
What if the only admin loses the password?
The console command kimai:user:password resets it; the commands page gives it for the case where no other admin exists and the forgot-password function is off. Here, run it with docker compose exec in the kimai container.
How soon is the server ready?
A racked machine is delivered in about 30 minutes once payment is approved, and payment review is the part that varies. Built-to-order lines need 4 to 24 hours.