Workload · Tracking your team's time

Self-hosted Kimai: your team's hours on a server you control

To self-host Kimai, you run its official Docker image next to a MySQL database on a server of your own, put HTTPS in front, create the first admin from the command line and back up the database together with Kimai's data and plugin folders every night. This guide walks you through each of those steps on one dedicated server, using Kimai's own documentation, and tells you where we chose differently and why.

The short version
  1. Kimai's official Docker image with MySQL, set up in one evening
  2. Nobody can sign up on their own unless you allow it
  3. HTTPS through Caddy, with the app port kept private
  4. Nightly backup and a restore you can actually follow

Before you start: what Kimai expects

Kimai runs on a MariaDB or MySQL database, PHP 8.2 or newer behind a web server, and a web address of its own such as time.example.com; its documentation says a folder like example.com/kimai is not supported. The Docker image already contains PHP and the web server, so your machine only needs Docker with Compose and a small HTTPS proxy.

Kimai's installation guide recommends self-hosting for experienced administrators and warns that mistakes can cost data. If nobody on your team will look after backups and updates, the project's hosted offer is the safer route. If someone will, read on.

The server that fits a team's time tracking

Kimai is one web app and one database, and its documentation publishes no hardware sizing per user. Think about disks before cores: the database grows with every entry, and your nightly copies wait beside it until you move them off.

  • A compact cartridge suits a small team that wants one job on one machine. Cartridges have no RAID card, and the screen-and-power console exists only on the m510 and m710x; the rest offer power control.
  • A single-processor rack server or blade adds headroom and that console, useful if a firewall mistake ever shuts you out. The enterprise lines offer hardware RAID controllers; see enterprise servers.
  • Not the X-Gene ARM model. Its system choices end at Ubuntu 18.04, and this guide needs 24.04.

Browse every option on the dedicated server page or what is ready today on instant servers. A ready server reaches you within 30 minutes of approval; a custom build takes hours rather than minutes. You manage the server; we keep the hardware and the network running. With no contract, moving to a bigger machine later is simple.

Getting Kimai running, step by step

  1. Install Ubuntu 24.04 from your control panel, then Docker Engine and the Docker Compose plugin using Docker's install guide.
  2. At your DNS provider, point time.example.com at your server (an A record, plus AAAA if you took the free IPv6). Wait until getent ahosts time.example.com on the server prints those addresses: HTTPS depends on it, so DNS always comes first.
  3. As root, paste the block below. It switches on ufw with its IPv6 rules and SSH allowed before anything else, opens the web ports, then makes the folders and a private settings file full of random passwords. Use your own name instead of time.example.com, but leave localhost and 127.0.0.1 in that line: Kimai's image checks its own health with curl -f http://127.0.0.1:8001 (the HEALTHCHECK in its Dockerfile), and that check needs those names allowed.
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw --force enable
ufw allow 80,443/tcp
mkdir -p -m 700 /srv/kimai
cd /srv/kimai
mkdir -m 700 data plugins mysql
install -m 600 /dev/null .env
cat >> .env <<EOF
DATABASE_NAME=kimai
DATABASE_USER=kimaiuser
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|time\.example\.com)$
EOF

The settings file gets owner-only permissions before a password goes into it. Hex passwords are deliberate: Kimai's documentation explains that characters like @ or / break DATABASE_URL unless encoded.

Create /srv/kimai/compose.yaml with the content below. It is the documentation's example that reads .env, changed by us in four ways: folders on the server instead of Docker volumes (simpler to back up), a fixed Kimai release, the app port reachable from the server itself only, and TRUSTED_PROXIES added. Its database healthcheck shows the MySQL root password to ps inside that container while it runs.

# save as /srv/kimai/compose.yaml
services:
  sqldb:
    image: mysql:8.3
    volumes:
      - /srv/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:
      - /srv/kimai/data:/opt/kimai/var/data
      - /srv/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

Make a shortcut so every later command works from any folder: echo "alias dc='docker compose -f /srv/kimai/compose.yaml'" >> /root/.bashrc && source /root/.bashrc. Start both containers with dc up -d and check them with dc ps. Hold off on the proxy for now: Kimai stays private until your admin account is sorted out.

Why 127.0.0.1: Docker publishes an address-less port mapping on all of the server's IPv4 and IPv6 addresses, where IPv4 traffic to it goes around ufw (IPv6 traffic too, if Docker manages IPv6 itself). The documentation's plain 8001:8001 would put Kimai's unencrypted port online. From Docker Engine 28 on, a 127.0.0.1 binding means local access only. The database publishes no port.

Your admin account, made privately

Create your account from the command line, so the website never waits empty for a stranger: dc exec -w /opt/kimai kimai php bin/console kimai:user:create jane jane@example.com ROLE_SUPER_ADMIN.

Username and email are required; skip the role and you get an ordinary user. Kimai then prompts Please enter the password: (the only question it asks), hides your typing, refuses an empty answer and allows three tries. That is how Kimai's source code writes the command (CreateUserCommand.php, AbstractUserCommand.php). Let it ask: a password typed into the command itself stays in your shell history.

The Docker page also offers ADMINMAIL and ADMINPASS, two lines in the kimai environment that create an admin "if not existing". According to .docker/entrypoint.sh the account is called admin, and every time the container starts, the password is handed to kimai:user:create as an argument, so ps can see it while that runs. It also sits in .env and in what docker inspect displays. If you go that way, give the account a new password before the site goes public, either with the console command from the FAQ below (kimai:user:password, username admin; it asks for the new one) or in the browser through your own tunnel: ssh -L 8001:127.0.0.1:8001 you@your-server, then open http://localhost:8001, which TRUSTED_HOSTS already allows. Afterwards take both lines out of compose.yaml and .env; dc up -d then rebuilds the container without them.

Can strangers sign up? Not unless you allow it. Kimai's source starts self-registration as false (src/DependencyInjection/Configuration.php), and the sign-up page answers "not found" while it is off (src/Controller/Security/SelfRegistrationController.php). The installation guide adds that the first person to register becomes the top administrator, one more reason to create yours first. Password-reset emails need mail settings too: MAILER_FROM and MAILER_URL, from the image documentation.

Going public over HTTPS, with TRUSTED_PROXIES

Once your admin password is yours alone, add the proxy: apt update && apt install -y caddy, write its site with printf 'time.example.com {\n\treverse_proxy 127.0.0.1:8001 {\n\t\tflush_interval -1\n\t}\n}\n' > /etc/caddy/Caddyfile, then systemctl reload caddy. Caddy gets the Let's Encrypt certificate for that name by itself, which is why DNS had to be ready first. From then on Caddy takes the secure connection and passes it to Kimai as plain HTTP. Kimai's web server page explains that unless Kimai trusts the proxy, links and redirects come out as http, so the login page may bounce you to an insecure address. TRUSTED_PROXIES fixes that and accepts IP addresses, ranges or Symfony keywords, never names. TRUSTED_HOSTS, which the image page says you must set, describes your web address.

Our explanation for the value: with the port published on 127.0.0.1, Docker delivers Caddy's requests from its internal bridge network, so Kimai sees a private address. The documentation's example, 127.0.0.1,PRIVATE_SUBNETS, covers it. Caddy adds the forwarding headers Kimai reads with no extra lines (Caddy's documentation); flush_interval -1 comes from Kimai's Caddy example.

Adding plugins

Kimai's Store on kimai.org lists the plugins; paid ones come through Kimai's License-Manager once bought. Each is a folder in /srv/kimai/plugins here.

  1. Unzip it so the folder matches the file inside, like /srv/kimai/plugins/FooBarBundle/FooBarBundle.php; rename a FooBarBundle-1.0 folder.
  2. Run dc restart kimai. Its start-up script, .docker/entrypoint.sh, clears the cache and returns the files to the web server's user.
  3. Plugins with database tables have their own install step; copy the exact command from that plugin's documentation. Kimai's plugin page describes the pattern as kimai:bundle:foobar:install, foobar standing for the plugin. Run it like the admin command above, in place of kimai:user:create, then restart again (our habit).

An empty .disabled file in its folder pauses a plugin; deleting the folder removes it; clear the cache either way. An error 500 after adding one usually means, per the docs, a plugin for another Kimai version, file permissions, or an install command that never ran.

Keeping your team's hours safe

Kimai's backup guide asks for a complete database dump plus the .env and local.yaml files and the data, export, invoices, packages and plugins folders under var/, whichever exist. The documented Docker setup keeps only the data and plugins folders outside the container, both under /srv/kimai here. Mount anything you add later (a local.yaml, custom templates) from that folder too, our advice, and the script archives it, since it takes the whole folder except the raw database files.

# /srv/kimai/backup.sh  (run with: bash /srv/kimai/backup.sh)
set -euo pipefail
umask 077
OUT=/srv/backups/kimai
NOW=$(date +%Y%m%d-%H%M)
mkdir -p -m 700 "$OUT"
cd /srv/kimai
docker compose -f /srv/kimai/compose.yaml exec -T sqldb sh -c \
  'exec mysqldump --single-transaction --no-tablespaces --user="$MYSQL_USER" --password="$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
  | gzip > "$OUT/db-$NOW.sql.gz.tmp"
mv "$OUT/db-$NOW.sql.gz.tmp" "$OUT/db-$NOW.sql.gz"
tar -czf "$OUT/files-$NOW.tar.gz.tmp" --exclude=./mysql .
mv "$OUT/files-$NOW.tar.gz.tmp" "$OUT/files-$NOW.tar.gz"
find "$OUT" -type f -mtime +14 -delete

pipefail makes a failed dump fail the script instead of saving an empty file that looks fine, and .tmp names mark half-written files. While the dump runs, its password shows in ps inside the container. Two weeks of copies is our pick. For a nightly run: mkdir -m 700 /var/log/kimai-nightly, then printf '30 3 * * * root umask 077; bash /srv/kimai/backup.sh > /var/log/kimai-nightly/run.log 2>&1\n' > /etc/cron.d/nightly-kimai.

Then get the files off the server: pull /srv/backups/kimai to another computer of yours with rsync or scp. A copy on the same disks is lost along with them.

Getting it back after a bad day

Kimai says to restore into the same version the backup came from. The archive holds compose.yaml with its fixed release, so that version returns with it. WHEN stands for the backup's stamp, such as 20261023-0330.

  1. Check the backup reads: gzip -t /srv/backups/kimai/db-WHEN.sql.gz && tar -tzf /srv/backups/kimai/files-WHEN.tar.gz > /dev/null && echo ok. No ok, no restore.
  2. Stop everything: dc down.
  3. Copy what is there now and check the copy: tar -czf /root/kimai-pre-restore.tar.gz -C /srv kimai && tar -tzf /root/kimai-pre-restore.tar.gz > /dev/null && echo ok. Carry on only after ok.
  4. Swap in the backup: mv /srv/kimai /srv/kimai.old && mkdir -m 700 /srv/kimai && tar -xzf /srv/backups/kimai/files-WHEN.tar.gz -C /srv/kimai && mkdir -m 700 /srv/kimai/mysql. Your old .env is in place before the database starts, which matters: the MySQL image reads it to create its database and account, but only when it starts with an empty folder.
  5. Start the database alone, dc up -d sqldb, until dc ps calls it healthy.
  6. Load the dump: gunzip -c /srv/backups/kimai/db-WHEN.sql.gz > /root/kimai.sql, then dc exec --no-TTY sqldb sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' < /root/kimai.sql && echo loaded.
  7. Start Kimai with dc up -d; its start-up script (.docker/entrypoint.sh) sets file ownership itself. Sign in and look at yesterday's entries.
  8. All good? rm -rf /srv/kimai.old /root/kimai.sql, and keep /root/kimai-pre-restore.tar.gz a while longer.

Updates, and what the AGPL asks of you

Kimai's update page wants a backup you have checked and a look at UPGRADING before each update; jumping several versions at once is fine. Here: run the backup, change the version on the image: line, then dc pull, dc down and dc up -d. Outside Docker you would finish with bin/console kimai:update; in the container, every start calls kimai:install -n (.docker/entrypoint.sh), and src/Command/InstallCommand.php makes kimai:update another name for that command, which applies the database migrations. The Docker page discourages a fixed version because it never updates by itself; we fix it so every restore matches its backup.

Kimai is released under AGPL-3.0, a copyleft licence. In general terms (our reading, not legal advice): an unchanged copy running on your own server creates no duty. Pass copies on, changed or not, and they must carry the licence and the source code. Change Kimai and let people use that changed copy through the network, and you owe them its source code. Bought plugins follow their own licence terms.

Questions people ask

Do I need the database container, or is the Kimai image enough?
You need both. The image holds Kimai, PHP and Apache; your data lives in MySQL or MariaDB, which the documentation's compose example runs alongside. The docs' quick one-off test is not for real use: its data vanishes with the containers.
Will MariaDB work instead of MySQL?
Yes. Write the exact MariaDB version into the serverVersion part of DATABASE_URL, for example 11.1.2-MariaDB, as the installation FAQ shows; a wrong value there causes errors during setup.
I forgot the only admin password. Now what?
kimai:user:password, a console command, sets a new one. Kimai documents it for the moment when no second admin exists and the reset-by-email feature is turned off. Run it as dc exec -w /opt/kimai kimai php bin/console kimai:user:password jane, and Kimai prompts for the new one.
How quickly can I start?
A ready server reaches you within 30 minutes of approval. The payment and fraud checks before approval are the part that can take longer, and a custom build takes hours.

Give your team's hours a home of their own

Choose a server, install Ubuntu 24.04 and follow this guide to put Kimai on it.

Get a server for Kimai

Read next