# Can I self-host Toggl Track?

**YES** — it's called Kimai. ONE EVENING setup · ~1.5 hours to running · 2 GB RAM minimum · $36/mo you stop paying ($432/yr on the Starter plan, 3 seats assumed).

Kimai authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/toggl-track/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Kimai 2.63.0 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user for both once and stop until
they answer. The A record for `<DOMAIN>` must already point at this server. `<ADMIN_EMAIL>` goes
on the first Kimai account, which signs in as `admin`, so it is an identifier, not a mailbox
this install writes to.

Kimai needs 2048 MB of RAM available and 10 GB free on /srv. It is PHP under Apache in front of
MySQL; both images publish amd64 and arm64. Measure all four first:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name that does not resolve, and failed attempts hit a rate limit.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/kimai /srv/kimai/backups
sudo install -d -m 700 /srv/kimai/mysql
sudo install -d -m 750 /srv/kimai/var
ls -la /srv/kimai
```

Assert: `ls -la` shows `backups` owned by the login user, `mysql` at mode `700` and `var`
present. Leave the last two alone. MySQL chowns its data directory on first start and Kimai
chowns /opt/kimai/var on every start, so after step 7 both belong to uids the images chose and
are read with sudo. Inside `var` sit the exports, invoices and plugins.

## 3. Secrets

Four secrets, all generated here: the MySQL root password, the MySQL password for the `kimai`
database user, `APP_SECRET`, and the password the container puts on the first admin account. Do
not print any of them, do not repeat them in your summary, and keep them out of every log.

Hex, not base64: the start-up script parses `DATABASE_URL` by splitting on `/`, `:` and `@` and
url-decoding the pieces, so any of those characters, or a `%`, breaks the wait-for-database loop
before Kimai runs.

```bash
umask 077
cat > /srv/kimai/.env <<EOF
DOMAIN_NAME=<DOMAIN>
ADMIN_EMAIL=<ADMIN_EMAIL>
DB_ROOT_PASSWORD=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
APP_SECRET=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/kimai/.env
umask 022
ls -l /srv/kimai/.env
```

Assert: the file exists with mode `-rw-------` and the login user's name twice. Docker Compose
reads it for the `${...}` substitutions in compose.yml whenever it runs from /srv/kimai, so it
is never mounted. `APP_SECRET` matters more than it looks: set here it lives in this file and
the backup; left out, the image writes one into a volume, and losing it invalidates every
session.

## 4. compose.yml

```bash
cat > /srv/kimai/compose.yml <<'EOF'
# Kimai · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose ... https://www.kimai.org/documentation/docker-compose.html
#   docker image ..... https://www.kimai.org/documentation/docker.html
#   backups .......... https://www.kimai.org/documentation/backups.html
#
# Two services: Kimai's Apache image and the MySQL holding every timesheet.
# Upstream supports MariaDB and MySQL only, so there is no SQLite path. Their
# example pins mysql:8.3, an innovation release out of support; 8.4 is the
# long-term line and DATABASE_URL says so. It also splits var/data from
# var/plugins, leaving invoices in an anonymous volume; this file mounts all of
# /opt/kimai/var, the path the image declares as a volume and the one the backup
# page asks you to keep. Digests read 2026-08-06; both images have arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  sqldb:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: kimai-db
    restart: unless-stopped
    command: --default-storage-engine innodb
    environment:
      MYSQL_DATABASE: kimai
      MYSQL_USER: kimai
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - /srv/kimai/mysql:/var/lib/mysql
    healthcheck:
      # Runs inside the container, where that value already is an env var.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u kimai -p$$MYSQL_PASSWORD --silent"]
      start_period: 30s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  kimai:
    image: kimai/kimai2:2.63.0@sha256:c0d55027c384b5f4e612dfeb326fdcff1d700dc469f85961b365eeb1c353119b
    container_name: kimai
    restart: unless-stopped
    environment:
      DATABASE_URL: "mysql://kimai:${DB_PASSWORD}@sqldb/kimai?charset=utf8mb4&serverVersion=8.4.0"
      APP_SECRET: ${APP_SECRET}
      # A regex Symfony matches the Host header against. 127.0.0.1 is in it
      # because the image's own HEALTHCHECK asks for that name.
      TRUSTED_HOSTS: localhost|127.0.0.1|${DOMAIN_NAME}
      # Caddy is on the host, so requests arrive from the docker bridge gateway.
      # Without these ranges Symfony ignores X-Forwarded-Proto and writes
      # http:// links on an https site.
      TRUSTED_PROXIES: 172.16.0.0/12,192.168.0.0/16,10.0.0.0/8
      # The start-up script creates the admin account while ADMINPASS is set.
      # Step 7 drops it from .env; `:-` keeps compose quiet once it is gone.
      ADMINMAIL: ${ADMIN_EMAIL}
      ADMINPASS: ${ADMIN_PASSWORD:-}
      memory_limit: 512M
    volumes:
      - /srv/kimai/var:/opt/kimai/var
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8126.
      - "127.0.0.1:8126:8001"
    depends_on:
      sqldb:
        condition: service_healthy
EOF
cd /srv/kimai && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Two services, one published port, a database with no host
port at all. Do not add one: nothing outside this project speaks to it.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-kimai
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Kimai · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.kimai.org/documentation/docker-compose.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also DOMAIN_NAME in .env, where it becomes the TRUSTED_HOSTS pattern, so the
# two stay the same string.

<DOMAIN> {
	# The interface is HTML, JavaScript and JSON; exports arrive compressed.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}
	# No Content-Security-Policy: invoice templates are editable here, and one
	# written without testing them breaks an invoice.

	# 8126 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8126
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-kimai, reload, and report what it objected to. Caddy requests the
certificate on the first request and renews it on its own.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a Prompt Zero box:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, 443/udp is
HTTP/3, 8126 stays closed because compose binds it to 127.0.0.1, and 3306 because compose never
publishes it. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule mentioning 8126 or 3306.

## 7. Start and verify

MySQL initialises, then Kimai's start-up script waits for it, builds the schema and creates one
account named `admin` with the password from step 3. First boot takes two to three minutes.

```bash
cd /srv/kimai
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/en/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/en/login | grep -o '<title>[^<]*</title>'
docker compose exec -T kimai /opt/kimai/bin/console kimai:user:list
```

Assert all three, and print what you received for each: the loop ends on `200`; the second
prints `<title>Kimai</title>`; the third prints a one-row table whose `Username` is `admin`,
whose `Roles` include `ROLE_SUPER_ADMIN` and whose `Active` reads `Yes`. If any of the three
misses, stop, run `docker compose logs --tail 40 kimai` and
`docker compose logs --tail 20 sqldb`, and name the likely earlier step: `502` means Caddy
reaches nothing on 8126, an empty user table means the container never saw `ADMIN_PASSWORD`, and
a script still printing `Wait for database connection` after five minutes points at step 2. A
running container is not success.

The first screen at https://<DOMAIN> redirects to https://<DOMAIN>/en/login, which shows the
wordmark `Kimai` over the line `Sign in to start your session`, a `Username` box, a `Password`
box and a `Sign In` button.

STOP: tell the user to read the password with `sudo grep ADMIN_PASSWORD /srv/kimai/.env`, put it
in their password manager, sign in at https://<DOMAIN> as `admin`, and confirm the dashboard
loads. Wait. Do not continue until they confirm: the next block deletes this server's copy.

Now close the bootstrap out. The start-up script runs under `bash -x`, so the account-creation
command, password included, was traced into the container log on first boot, and it repeats on
every restart while `ADMINPASS` still has a value:

```bash
cd /srv/kimai
sed -i '/^ADMIN_PASS/d' /srv/kimai/.env
docker compose up -d --force-recreate kimai
sleep 60
docker compose logs kimai | grep -c 'kimai:user:create' || true
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/en/login
```

Assert both: the count prints `0` and the status prints `200`. That `0` is the security assert
in this block. The old container was replaced and its log file went with it, so the traced
password is off the box, and the deleted `ADMIN_PASSWORD` line stops the script recreating the
account, and retracing it, on every start from here. A count above `0` means the edit did not
take: check `grep -c '^ADMIN_PASS' /srv/kimai/.env` and run the block again. The user's password
manager now holds the only copy; if they lose it, recovery is
`docker compose exec -it kimai /opt/kimai/bin/console kimai:user:password admin`, which asks on
the terminal rather than taking a password on a command line.

## 8. First backup and restore

Two artifacts. The database holds the customers, projects, timesheets and rates. The file
archive holds compose.yml, .env, the Caddy site block and `var`, where exports and invoices
live.

```bash
cd /srv/kimai
docker compose exec -T sqldb sh -c 'mysqldump --single-transaction --no-tablespaces -u kimai -p"$MYSQL_PASSWORD" kimai' | gzip > /srv/kimai/backups/kimai-db-$(date +%F).sql.gz
sudo tar -czf /srv/kimai/backups/kimai-files-$(date +%F).tar.gz -C /srv/kimai compose.yml .env var -C /etc/caddy Caddyfile
ls -lh /srv/kimai/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing goes offline:
`--single-transaction` snapshots a running InnoDB database consistently, and `--no-tablespaces`
is there because the `kimai` user is not a superuser and the dump fails without it. The password
is read inside the database container, so it never reaches the host process list.

A backup on the same disk is not a backup, so run this from the user's machine:

```bash
mkdir -p ~/backups/kimai
scp vps:/srv/kimai/backups/* ~/backups/kimai/
```

To restore, in this order. Untar the file archive into /srv/kimai first, so .env is back before
any container starts: MySQL takes its passwords from that file the moment it initialises an
empty data directory, and a missing .env means a blank password and a database that never
starts. Then `docker compose down`, `sudo rm -rf /srv/kimai/mysql`, recreate as in step 2,
`docker compose up -d sqldb`, wait a minute for healthy, then
`gunzip -c /srv/kimai/backups/kimai-db-<date>.sql.gz | docker compose exec -T sqldb sh -c 'mysql -u kimai -p"$MYSQL_PASSWORD" kimai'`,
then `docker compose up -d`. Tell the user what matters at 2am: an hour they billed and cannot
prove is an hour they do not get paid for, so the dump is the invoice, not the app.

## 9. Updating later

New versions are listed at https://github.com/kimai/kimai/releases. Kimai ships one most months
and each migrates the database on the way up, so take both backups first, then edit the image
line in /srv/kimai/compose.yml to the new tag and digest:

```bash
cd /srv/kimai
docker compose pull
docker compose up -d
docker compose logs --tail 30 kimai
```

Watch that log until it settles, then re-run step 7's first two checks before calling it done.

## 10. What will probably go wrong

The first `docker compose logs kimai` looks like a catastrophe and is not. The start-up script
has `bash -x` on its first line, so every command it runs is echoed with a `+` in front of it,
interleaved with `Testing DB:` from the wait loop, and `docker compose ps` says `unhealthy` for
a minute or two because the image's health check polls immediately and gives up after three
tries. I read that as a crashed install and started pulling the compose file apart before the
login page came up on its own. Give step 7's loop its full 40 rounds first.

## 11. Out of scope

- Do not configure SMTP or set `MAILER_URL`. Kimai runs with mail off; the cost is
  password-reset and notification email, and the admin creates accounts by hand.
- Do not enable LDAP or SAML. Both need an identity provider this install does not have, and
  both change how the account from step 7 signs in.
- Do not install plugins from the Kimai store. They need a cache rebuild of their own, and a
  broken one takes the application down.
- Do not turn on self-registration in a `local.yaml`. It is off by default and this host is
  public.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing Kimai 2.63.0 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, replace `<DOMAIN>` with the hostname whose A record already points at the
box, and replace `<ADMIN_EMAIL>` with the address you want on the first account.

Two things to decide before you start. `<DOMAIN>` becomes the `TRUSTED_HOSTS` pattern Symfony
checks every request against, so it has to be the name you will actually use. `<ADMIN_EMAIL>`
is an identifier on an account that signs in as `admin`; this install sends no mail, so it does
not have to be a mailbox that works.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. Under 2048 MB of RAM is
the one to take seriously here: this is PHP under Apache plus a MySQL, and the OOM killer
arrives during the first schema build rather than at a moment that tells you why.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/kimai /srv/kimai/backups
sudo install -d -m 700 /srv/kimai/mysql
sudo install -d -m 750 /srv/kimai/var
ls -la /srv/kimai
```

You should see: `backups` owned by you, `mysql` at mode `drwx------` owned by root, and `var`
alongside them.

If you do not: leave `mysql` and `var` owned by root on purpose. MySQL chowns its data
directory the first time it starts, and Kimai chowns /opt/kimai/var to its own web user on
every start, so both end up owned by uids the images picked. After step 7 you read either of
them with `sudo`, and that is the images working as designed rather than a permissions bug.

## 3. Secrets

Four secrets: the MySQL root password, the MySQL password for the `kimai` database user,
`APP_SECRET`, and the password the container puts on the first admin account. All four are
generated here, on the server, into a file only you can read. Hex rather than base64, because
the container's start-up script parses `DATABASE_URL` by splitting it on `/`, `:` and `@` and
then url-decoding the pieces: a password holding any of those characters, or a `%`, breaks the
wait-for-database loop before Kimai ever runs.

```bash
umask 077
cat > /srv/kimai/.env <<EOF
DOMAIN_NAME=<DOMAIN>
ADMIN_EMAIL=<ADMIN_EMAIL>
DB_ROOT_PASSWORD=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
APP_SECRET=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/kimai/.env
umask 022
ls -l /srv/kimai/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>`
and `<ADMIN_EMAIL>` on the first two lines with your real values before you paste.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/kimai/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten all
four values, which is fine before the database exists and a problem afterwards: MySQL keeps the
passwords it was created with, so a changed `DB_PASSWORD` against an existing data directory
shows up as an access-denied loop in the Kimai log rather than as anything about passwords.

Do not paste that file, any of those four values, or any command output containing them into
this chat window. The agent path never shows them to anybody; this path will hand them to a
third party unless you keep them out of the box you are typing in.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/kimai/compose.yml <<'EOF'
# Kimai · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose ... https://www.kimai.org/documentation/docker-compose.html
#   docker image ..... https://www.kimai.org/documentation/docker.html
#   backups .......... https://www.kimai.org/documentation/backups.html
#
# Two services: Kimai's Apache image and the MySQL holding every timesheet.
# Upstream supports MariaDB and MySQL only, so there is no SQLite path. Their
# example pins mysql:8.3, an innovation release out of support; 8.4 is the
# long-term line and DATABASE_URL says so. It also splits var/data from
# var/plugins, leaving invoices in an anonymous volume; this file mounts all of
# /opt/kimai/var, the path the image declares as a volume and the one the backup
# page asks you to keep. Digests read 2026-08-06; both images have arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  sqldb:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: kimai-db
    restart: unless-stopped
    command: --default-storage-engine innodb
    environment:
      MYSQL_DATABASE: kimai
      MYSQL_USER: kimai
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - /srv/kimai/mysql:/var/lib/mysql
    healthcheck:
      # Runs inside the container, where that value already is an env var.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u kimai -p$$MYSQL_PASSWORD --silent"]
      start_period: 30s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  kimai:
    image: kimai/kimai2:2.63.0@sha256:c0d55027c384b5f4e612dfeb326fdcff1d700dc469f85961b365eeb1c353119b
    container_name: kimai
    restart: unless-stopped
    environment:
      DATABASE_URL: "mysql://kimai:${DB_PASSWORD}@sqldb/kimai?charset=utf8mb4&serverVersion=8.4.0"
      APP_SECRET: ${APP_SECRET}
      # A regex Symfony matches the Host header against. 127.0.0.1 is in it
      # because the image's own HEALTHCHECK asks for that name.
      TRUSTED_HOSTS: localhost|127.0.0.1|${DOMAIN_NAME}
      # Caddy is on the host, so requests arrive from the docker bridge gateway.
      # Without these ranges Symfony ignores X-Forwarded-Proto and writes
      # http:// links on an https site.
      TRUSTED_PROXIES: 172.16.0.0/12,192.168.0.0/16,10.0.0.0/8
      # The start-up script creates the admin account while ADMINPASS is set.
      # Step 7 drops it from .env; `:-` keeps compose quiet once it is gone.
      ADMINMAIL: ${ADMIN_EMAIL}
      ADMINPASS: ${ADMIN_PASSWORD:-}
      memory_limit: 512M
    volumes:
      - /srv/kimai/var:/opt/kimai/var
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8126.
      - "127.0.0.1:8126:8001"
    depends_on:
      sqldb:
        condition: service_healthy
EOF
cd /srv/kimai && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal, so run `rm /srv/kimai/compose.yml` and paste again in one go. A warning that a
variable is not set means step 3 wrote the .env somewhere other than /srv/kimai, or you are not
in /srv/kimai: compose reads that file from the directory you run it in. Note what is not here:
no SQLite option, because Kimai supports MariaDB and MySQL only, and no host port on the
database at all.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-kimai
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Kimai · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.kimai.org/documentation/docker-compose.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also DOMAIN_NAME in .env, where it becomes the TRUSTED_HOSTS pattern, so the
# two stay the same string.

<DOMAIN> {
	# The interface is HTML, JavaScript and JSON; exports arrive compressed.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}
	# No Content-Security-Policy: invoice templates are editable here, and one
	# written without testing them breaks an invoice.

	# 8126 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8126
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-kimai /etc/caddy/Caddyfile`, reload,
and paste again. Caddy terminates TLS and speaks plain http to the container, which is why the
compose file lists three private network ranges in `TRUSTED_PROXIES`: requests reach the
container from the Docker bridge gateway, and without those ranges Symfony ignores
`X-Forwarded-Proto` and writes `http://` links into an https site.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8126` or `3306`.

If you do not: delete anything for those two with `sudo ufw delete allow 8126`. 8126 is bound
to 127.0.0.1 by the compose file and 3306 is never published at all, so the database has no host
port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS and answer the ACME
challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default.
`Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so something
has turned it off since, and `sudo ufw enable` puts it back before you go any further.

## 7. Start and verify

MySQL initialises its data directory, then Kimai's start-up script waits for it, builds the
schema and creates one account named `admin` with the password from step 3. First boot takes
two to three minutes, and the log looks alarming the whole time. Read step 10 now if you have
not.

```bash
cd /srv/kimai
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/en/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/en/login | grep -o '<title>[^<]*</title>'
docker compose exec -T kimai /opt/kimai/bin/console kimai:user:list
```

You should see, in order: the loop reaching `200`, then `<title>Kimai</title>`, then a one-row
table whose `Username` is `admin`, whose `Roles` include `ROLE_SUPER_ADMIN` and whose `Active`
column reads `Yes`.

If you do not: a `502` from the loop means Caddy is reaching nothing on 8126, so check
`docker compose ps` and then `docker compose logs --tail 40 kimai`. An empty user table means
the container never saw `ADMIN_PASSWORD`, which is step 3 written to the wrong place. A log
still printing `Wait for database connection` after five minutes means MySQL never came up, so
read `docker compose logs --tail 20 sqldb`. A running container is not success; all three of
these have to pass.

In a browser, https://<DOMAIN> redirects to https://<DOMAIN>/en/login, which shows the wordmark
`Kimai` over the line `Sign in to start your session`, a `Username` box, a `Password` box and a
`Sign In` button.

Read your password once, sign in, and confirm the dashboard loads before you go on, because the
next block deletes the server's copy of it:

```bash
sudo grep ADMIN_PASSWORD /srv/kimai/.env
```

You should see: one line. Put that value in your password manager now, then sign in at
https://<DOMAIN> as `admin`.

If you do not: an empty result means step 3 did not run in this shell. Go back rather than
inventing a password here.

Now close the bootstrap out. The container's start-up script has `bash -x` on its first line,
so the account-creation command, password included, was traced into the container log on first
boot, and it repeats on every restart while `ADMINPASS` still has a value:

```bash
cd /srv/kimai
sed -i '/^ADMIN_PASS/d' /srv/kimai/.env
docker compose up -d --force-recreate kimai
sleep 60
docker compose logs kimai | grep -c 'kimai:user:create' || true
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/en/login
```

You should see: `0` from the count, then `200` from the status.

If you do not: a count above `0` means the .env line is still there, so run
`grep -c '^ADMIN_PASS' /srv/kimai/.env`, confirm it prints `0`, and repeat the block. That `0`
is the security check in this step: recreating the container replaced the log file that held
the traced password, and the deleted line stops the script writing it again on every start.
From here your password manager holds the only copy. If you lose it,
`docker compose exec -it kimai /opt/kimai/bin/console kimai:user:password admin` asks for a new
one on the terminal instead of taking it on a command line.

## 8. First backup and restore

Two artifacts. The database holds the customers, projects, timesheets and rates. The file
archive holds compose.yml, .env, the Caddy site block and `var`, where exports and invoices
live.

```bash
cd /srv/kimai
docker compose exec -T sqldb sh -c 'mysqldump --single-transaction --no-tablespaces -u kimai -p"$MYSQL_PASSWORD" kimai' | gzip > /srv/kimai/backups/kimai-db-$(date +%F).sql.gz
sudo tar -czf /srv/kimai/backups/kimai-files-$(date +%F).tar.gz -C /srv/kimai compose.yml .env var -C /etc/caddy Caddyfile
ls -lh /srv/kimai/backups/
```

You should see: two files, the dump a few tens of kilobytes on a fresh install and the archive
larger. Nothing goes offline: `--single-transaction` snapshots a running InnoDB database
consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mysqldump` failed
and the shell created the file anyway. Run the dump line without `| gzip` to read the error.
`Access denied; you need ... the PROCESS privilege` means `--no-tablespaces` was dropped: the
`kimai` user is not a superuser, and that flag is what keeps the dump inside its own database.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/kimai
scp vps:/srv/kimai/backups/* ~/backups/kimai/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/kimai/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:`
prefix only means something on your own machine, where the `vps` alias Prompt Zero created
lives.

Now prove the restore, today, while the only thing at risk is an empty timesheet:

```bash
cd /srv/kimai
sudo tar -xzf /srv/kimai/backups/kimai-files-$(date +%F).tar.gz -C /srv/kimai compose.yml .env
docker compose down
sudo rm -rf /srv/kimai/mysql
sudo install -d -m 700 /srv/kimai/mysql
docker compose up -d sqldb
sleep 60
gunzip -c /srv/kimai/backups/kimai-db-$(date +%F).sql.gz | docker compose exec -T sqldb sh -c 'mysql -u kimai -p"$MYSQL_PASSWORD" kimai'
docker compose up -d
sleep 60
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/en/login
```

You should see: no output from the `gunzip` line, then `200` from the last command, then your
own account still working when you sign in.

If you do not: `Access denied for user 'kimai'` means the .env was not back before the database
container initialised the empty directory, which is why the untar is the first line rather than
the last. `ERROR 2002` means MySQL had not finished starting, so wait longer and run the
`gunzip` line again. Understand what is at stake: an hour you billed and cannot prove is an
hour you do not get paid for, so this dump is the invoice, not the app.

## 9. Updating later

New versions are listed at https://github.com/kimai/kimai/releases. Kimai ships one most months
and each migrates the database on the way up, so take both backup artifacts first, then edit
the `image:` line in /srv/kimai/compose.yml to the new tag and its digest.

```bash
cd /srv/kimai
docker compose pull
docker compose up -d
docker compose logs --tail 30 kimai
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
first two checks from step 7 before you call the update done, because a login page that answers
`200` can still sit in front of a migration that stopped halfway.

## 10. What will probably go wrong

The first `docker compose logs kimai` looks like a catastrophe and is not. The start-up script
has `bash -x` on its first line, so every command it runs is echoed with a `+` in front of it,
interleaved with `Testing DB:` from the wait loop, and `docker compose ps` says `unhealthy` for
the first minute or two because the image's health check starts polling immediately and gives
up after three tries. I read that as a crashed install and started pulling the compose file
apart before the login page came up on its own. Give step 7's loop its full 40 rounds first.

## 11. Out of scope

- Do not configure SMTP or set `MAILER_URL`. Kimai runs with mail off; the cost is
  password-reset and notification email, and the admin creates accounts by hand.
- Do not enable LDAP or SAML. Both need an identity provider this install does not have, and
  both change how the account from step 7 signs in.
- Do not install plugins from the Kimai store. They need a cache rebuild of their own, and a
  broken one takes the application down.
- Do not turn on self-registration in a `local.yaml`. It is off by default and this host is
  public.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Kimai 2.63.0, with the MySQL it keeps every timesheet in, under ~/selfhost/kimai,
answering at http://localhost:8126.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
Kimai answers at http://localhost:8126 and nowhere else: not on their phone, not across the
room, not for a colleague billing the same job. A timer they cannot start from where they work
is one they forget, and forgotten hours go uninvoiced.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Kimai plus MySQL needs 2048 MB of RAM
available and 10 GB free on the home disk; both images publish amd64 and arm64. On macOS and
Windows that memory figure is the host's, out of which Docker Desktop takes its own. If either
floor is missed, print both numbers and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/kimai/backups
ls -la ~/selfhost/kimai
```

Assert: `ls -la` shows `backups`, owned by the user. There is no data folder: both images chown
their data directory to a uid they pick, which a home bind mount cannot grant on Windows, so
step 5 uses Docker volumes.

## 4. Secrets

Four secrets, all generated here: the MySQL root password, the `kimai` user's database password,
`APP_SECRET`, and the one the container puts on the first admin account. Print none of them and
keep them out of your summary and every log line. Hex, not base64: the start-up script
splits `DATABASE_URL` on `/`, `:` and `@`, so any of those, or a `%`, breaks its wait loop.

```bash
umask 077
cat > ~/selfhost/kimai/.env <<EOF
ADMIN_EMAIL=admin@localhost
DB_ROOT_PASSWORD=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
APP_SECRET=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 ~/selfhost/kimai/.env
umask 022
ls -l ~/selfhost/kimai/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these run the same on
all three systems. Compose reads it for the `${...}` substitutions in compose.yml, so it is
never mounted, and the address is only a label on an account that signs in as `admin`. On
Windows those mode bits are advisory: NTFS does not enforce them and the user's own account is
the boundary.

## 5. compose.yml

```bash
cat > ~/selfhost/kimai/compose.yml <<'EOF'
# Kimai · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker compose ... https://www.kimai.org/documentation/docker-compose.html
#   docker image ..... https://www.kimai.org/documentation/docker.html
#   backups .......... https://www.kimai.org/documentation/backups.html
#
# Two services, run from ~/selfhost/kimai/. Both data directories are named
# volumes, not bind mounts: MySQL chowns /var/lib/mysql and Kimai chowns
# /opt/kimai/var, each to a uid it picks, which a home bind mount cannot grant
# on Windows. Digests read 2026-08-06; both images have arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  sqldb:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: kimai-db
    restart: unless-stopped
    command: --default-storage-engine innodb
    environment:
      MYSQL_DATABASE: kimai
      MYSQL_USER: kimai
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - kimai-mysqldata:/var/lib/mysql
    healthcheck:
      # Runs in the container, where that value already is an env var.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u kimai -p$$MYSQL_PASSWORD --silent"]
      start_period: 30s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the kimai container.

  kimai:
    image: kimai/kimai2:2.63.0@sha256:c0d55027c384b5f4e612dfeb326fdcff1d700dc469f85961b365eeb1c353119b
    container_name: kimai
    restart: unless-stopped
    environment:
      DATABASE_URL: "mysql://kimai:${DB_PASSWORD}@sqldb/kimai?charset=utf8mb4&serverVersion=8.4.0"
      APP_SECRET: ${APP_SECRET}
      # A regex Symfony matches the Host header against.
      TRUSTED_HOSTS: localhost|127.0.0.1
      # Created while ADMINPASS is set; step 7 drops that line from .env.
      ADMINMAIL: ${ADMIN_EMAIL}
      ADMINPASS: ${ADMIN_PASSWORD:-}
      memory_limit: 512M
    volumes:
      - kimai-vardata:/opt/kimai/var
    ports:
      # Loopback only: no device on the wifi can reach 8126.
      - "127.0.0.1:8126:8001"
    depends_on:
      sqldb:
        condition: service_healthy

volumes:
  kimai-mysqldata:
  kimai-vardata:
EOF
cd ~/selfhost/kimai && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Two services, one port, two volumes.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so pages needing crypto work.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8126 is bound to 127.0.0.1: not the phone, not a laptop on the wifi, nobody outside. Confirm:

```bash
grep -n '127.0.0.1:8126' ~/selfhost/kimai/compose.yml
```

Assert: one line, `- "127.0.0.1:8126:8001"`. MySQL publishes no host port.

## 7. Start and verify

MySQL initialises, Kimai's start-up script waits for it, builds the schema and creates an
account named `admin` with the password from step 4. First boot takes two or three minutes.

```bash
cd ~/selfhost/kimai
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8126/en/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8126/en/login | grep -o '<title>[^<]*</title>'
docker compose exec -T kimai /opt/kimai/bin/console kimai:user:list
```

Assert all three and print what you got: the loop ends on `200`; the second prints
`<title>Kimai</title>`; the third prints a one-row table with `Username` `admin`, `Roles`
including `ROLE_SUPER_ADMIN` and `Active` `Yes`. On any miss, stop, run
`docker compose logs --tail 40 kimai`, and name the cause: an empty user table means the
container never saw `ADMIN_PASSWORD`; `Wait for database connection` after five minutes means
MySQL never came up; `port is already allocated` means something else holds 8126
(`lsof -nP -iTCP:8126 -sTCP:LISTEN`, or `netstat -ano | findstr :8126`). A running container is
not success.

The first screen at http://localhost:8126 redirects to /en/login: the wordmark `Kimai` over the
line `Sign in to start your session`, a `Username` box, a `Password` box and a `Sign In` button.

STOP: tell the user to read the password with `grep ADMIN_PASSWORD ~/selfhost/kimai/.env`, put
it in their password manager, sign in as `admin`, and confirm the dashboard loads. Wait. Do not
continue until they confirm: the next block deletes this machine's copy.

Now close the bootstrap out. The start-up script runs under `bash -x`, so the account-creation
command, password included, was traced into the container log on first boot, and repeats on
every start while `ADMINPASS` is set:

```bash
cd ~/selfhost/kimai
(umask 077; grep -v '^ADMIN_PASS' .env > .env.new) && mv .env.new .env && chmod 600 .env
docker compose up -d --force-recreate kimai
sleep 60
docker compose logs kimai | grep -c 'kimai:user:create' || true
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8126/en/login
```

Assert both: the count prints `0` and the status prints `200`. That `0` is the security assert
here: the old container was replaced and its log file went with it, and the deleted line stops
the script recreating the account on every start. A count above `0` means the edit did not take,
so check `grep -c '^ADMIN_PASS' .env` and run it again. If the password is lost, recovery is
`docker compose exec -it kimai /opt/kimai/bin/console kimai:user:password admin`.

## 8. First backup and restore

Two artifacts. The database is the system of record: customers, projects, timesheets, rates and
invoices. The config archive holds the two files that rebuild it.

```bash
cd ~/selfhost/kimai
docker compose exec -T sqldb sh -c 'mysqldump --single-transaction --no-tablespaces -u kimai -p"$MYSQL_PASSWORD" kimai' | gzip > backups/kimai-db-$(date +%F).sql.gz
tar -C ~/selfhost/kimai -czf backups/kimai-config-$(date +%F).tar.gz compose.yml .env
ls -lh backups/
```

Assert: both exist and neither is empty. Print both sizes. Nothing goes offline:
`--single-transaction` snapshots a running InnoDB database consistently, `--no-tablespaces` is
there because the `kimai` user is not a superuser, and the password is read inside the database
container so it never reaches this computer's process list. Say this too: Kimai's `var` volume,
holding rendered exports and invoices, is not in the backup: it re-renders them from the rows
that are.

Both sit on the same disk as the data, which is not a backup, and on a laptop the disk and the
machine fail together. Ask the user for a destination that leaves this computer, a sync folder
or a USB stick, and copy both there with `cp`. In Git Bash a Windows drive is `/d/Backups`, not
`D:\Backups`. Assert: the user confirms both names are there, or say there is no backup.

To restore, in this order. Untar the config archive into ~/selfhost/kimai first, so .env is back
before any container starts: MySQL reads its passwords from that file the moment it initialises
an empty volume, and a missing .env means a blank password and a database that never starts.
Then `docker compose down -v`, the one place `-v` belongs because it drops the old volumes on
purpose, then `docker compose up -d sqldb`, wait a minute, and run
`gunzip -c backups/kimai-db-<date>.sql.gz | docker compose exec -T sqldb sh -c 'mysql -u kimai -p"$MYSQL_PASSWORD" kimai'`,
then `docker compose up -d`. Sign in and check an entry is there. An hour billed and not
provable is an hour that does not get paid.

## 9. Updating later

New versions are listed at https://github.com/kimai/kimai/releases. Kimai ships one most months
and each migrates the database on the way up, so back up first, then edit the image line:

```bash
cd ~/selfhost/kimai
docker compose pull
docker compose up -d
docker compose logs --tail 30 kimai
```

Watch it until it settles, then re-run step 7's first two checks.

## 10. What will probably go wrong

I rebooted this machine, opened http://localhost:8126 to start a timer, and got a connection
refused that reads like a broken install. It was not: Docker Desktop had not started with the
session, so nothing was listening on 8126 and no hours were recorded until it did.
`restart: unless-stopped` acts only once the Docker daemon is up. Turn on its start-at-login
setting, and after a reboot run `cd ~/selfhost/kimai && docker compose up -d` first.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8126 to 0.0.0.0 so a phone can reach it. That puts a login form holding the
  user's billing rates on every network they join.
- Do not configure SMTP, enable LDAP or SAML, or install plugins from the Kimai store. Each
  needs something this install lacks, and a broken plugin takes the app down.
````

## docker-compose.yml

```yaml
# Kimai · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose ... https://www.kimai.org/documentation/docker-compose.html
#   docker image ..... https://www.kimai.org/documentation/docker.html
#   backups .......... https://www.kimai.org/documentation/backups.html
#
# Two services: Kimai's Apache image and the MySQL holding every timesheet.
# Upstream supports MariaDB and MySQL only, so there is no SQLite path. Their
# example pins mysql:8.3, an innovation release out of support; 8.4 is the
# long-term line and DATABASE_URL says so. It also splits var/data from
# var/plugins, leaving invoices in an anonymous volume; this file mounts all of
# /opt/kimai/var, the path the image declares as a volume and the one the backup
# page asks you to keep. Digests read 2026-08-06; both images have arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  sqldb:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: kimai-db
    restart: unless-stopped
    command: --default-storage-engine innodb
    environment:
      MYSQL_DATABASE: kimai
      MYSQL_USER: kimai
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - /srv/kimai/mysql:/var/lib/mysql
    healthcheck:
      # Runs inside the container, where that value already is an env var.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u kimai -p$$MYSQL_PASSWORD --silent"]
      start_period: 30s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  kimai:
    image: kimai/kimai2:2.63.0@sha256:c0d55027c384b5f4e612dfeb326fdcff1d700dc469f85961b365eeb1c353119b
    container_name: kimai
    restart: unless-stopped
    environment:
      DATABASE_URL: "mysql://kimai:${DB_PASSWORD}@sqldb/kimai?charset=utf8mb4&serverVersion=8.4.0"
      APP_SECRET: ${APP_SECRET}
      # A regex Symfony matches the Host header against. 127.0.0.1 is in it
      # because the image's own HEALTHCHECK asks for that name.
      TRUSTED_HOSTS: localhost|127.0.0.1|${DOMAIN_NAME}
      # Caddy is on the host, so requests arrive from the docker bridge gateway.
      # Without these ranges Symfony ignores X-Forwarded-Proto and writes
      # http:// links on an https site.
      TRUSTED_PROXIES: 172.16.0.0/12,192.168.0.0/16,10.0.0.0/8
      # The start-up script creates the admin account while ADMINPASS is set.
      # Step 7 drops it from .env; `:-` keeps compose quiet once it is gone.
      ADMINMAIL: ${ADMIN_EMAIL}
      ADMINPASS: ${ADMIN_PASSWORD:-}
      memory_limit: 512M
    volumes:
      - /srv/kimai/var:/opt/kimai/var
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8126.
      - "127.0.0.1:8126:8001"
    depends_on:
      sqldb:
        condition: service_healthy
```

## compose.local.yml

```yaml
# Kimai · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker compose ... https://www.kimai.org/documentation/docker-compose.html
#   docker image ..... https://www.kimai.org/documentation/docker.html
#   backups .......... https://www.kimai.org/documentation/backups.html
#
# Two services, run from ~/selfhost/kimai/. Both data directories are named
# volumes, not bind mounts: MySQL chowns /var/lib/mysql and Kimai chowns
# /opt/kimai/var, each to a uid it picks, which a home bind mount cannot grant
# on Windows. Digests read 2026-08-06; both images have arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  sqldb:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: kimai-db
    restart: unless-stopped
    command: --default-storage-engine innodb
    environment:
      MYSQL_DATABASE: kimai
      MYSQL_USER: kimai
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - kimai-mysqldata:/var/lib/mysql
    healthcheck:
      # Runs in the container, where that value already is an env var.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u kimai -p$$MYSQL_PASSWORD --silent"]
      start_period: 30s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the kimai container.

  kimai:
    image: kimai/kimai2:2.63.0@sha256:c0d55027c384b5f4e612dfeb326fdcff1d700dc469f85961b365eeb1c353119b
    container_name: kimai
    restart: unless-stopped
    environment:
      DATABASE_URL: "mysql://kimai:${DB_PASSWORD}@sqldb/kimai?charset=utf8mb4&serverVersion=8.4.0"
      APP_SECRET: ${APP_SECRET}
      # A regex Symfony matches the Host header against.
      TRUSTED_HOSTS: localhost|127.0.0.1
      # Created while ADMINPASS is set; step 7 drops that line from .env.
      ADMINMAIL: ${ADMIN_EMAIL}
      ADMINPASS: ${ADMIN_PASSWORD:-}
      memory_limit: 512M
    volumes:
      - kimai-vardata:/opt/kimai/var
    ports:
      # Loopback only: no device on the wifi can reach 8126.
      - "127.0.0.1:8126:8001"
    depends_on:
      sqldb:
        condition: service_healthy

volumes:
  kimai-mysqldata:
  kimai-vardata:
```

## Caddyfile

```text
# Kimai · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.kimai.org/documentation/docker-compose.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also DOMAIN_NAME in .env, where it becomes the TRUSTED_HOSTS pattern, so the
# two stay the same string.

<DOMAIN> {
	# The interface is HTML, JavaScript and JSON; exports arrive compressed.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}
	# No Content-Security-Policy: invoice templates are editable here, and one
	# written without testing them breaks an invoice.

	# 8126 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8126
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Kimai · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=time.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://www.kimai.org/documentation/docker-compose.html
#   https://www.kimai.org/documentation/docker.html
#   https://www.kimai.org/documentation/installation.html
#   https://www.kimai.org/documentation/backups.html
#
# Four secrets are generated here, on this machine: the MySQL root password, the
# MySQL password for the kimai database user, APP_SECRET, and the password the
# container puts on the first admin account. All four go into /srv/kimai/.env
# with mode 600 and none is ever printed.
#
# DOMAIN_HOST is also TRUSTED_HOSTS, the pattern Symfony matches every incoming
# Host header against, so it has to be the name you will actually use.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/kimai}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. time.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address for the first Kimai account"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; PHP plus MySQL wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# mysql and var stay root-owned here. Both images chown their own data directory
# to a uid of their choosing on first start, and this script does not fight them.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/mysql"
sudo install -d -m 750 "$APP_DIR/var"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the four secrets, on the server -----------------------------
#
# Hex for all four: the container's start-up script splits DATABASE_URL on /, :
# and @ and url-decodes the pieces, so any of those characters, or a %, breaks
# its wait-for-database loop. Read them later with
#   sudo grep -E 'DB_PASSWORD|APP_SECRET|ADMIN_PASSWORD' /srv/kimai/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		DOMAIN_NAME=${DOMAIN_HOST}
		ADMIN_EMAIL=${ADMIN_EMAIL}
		DB_ROOT_PASSWORD=$(openssl rand -hex 32)
		DB_PASSWORD=$(openssl rand -hex 32)
		APP_SECRET=$(openssl rand -hex 32)
		ADMIN_PASSWORD=$(openssl rand -hex 24)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-kimai"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8126 nor 3306 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8126 and 3306 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# MySQL initialises its data directory, then Kimai's start-up script waits for
# it, builds the schema and creates the admin account named in ADMIN_EMAIL.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/en/login"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/en/login" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/en/login answered ${code:-nothing}. Check: docker compose logs --tail 40 kimai"

curl -sS "https://${DOMAIN_HOST}/en/login" | grep -q '<title>Kimai</title>' \
	|| die "the login page answered 200 without a Kimai title. Check: docker compose logs --tail 40 kimai"

# The account has to exist before the summary tells anyone to sign in.
docker compose exec -T kimai /opt/kimai/bin/console kimai:user:list | grep -q 'ROLE_SUPER_ADMIN' \
	|| die "no super admin account was created. Check that ADMIN_PASSWORD is set in $APP_DIR/.env"

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T sqldb sh -c 'mysqldump --single-transaction --no-tablespaces -u kimai -p"$MYSQL_PASSWORD" kimai' \
	| gzip > "$APP_DIR/backups/kimai-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/kimai-files-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env var -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/kimai-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	Kimai is answering at https://${DOMAIN_HOST}/en/login

	  1. Read the password for the admin account now:
	       sudo grep ADMIN_PASSWORD $APP_DIR/.env
	     It was not printed here. Put it in your password manager, then sign in
	     at https://${DOMAIN_HOST} with the username admin.
	  2. Once you are signed in, close the bootstrap out. The container's
	     start-up script runs under bash -x, so it traced that password into the
	     container log on first boot and repeats it on every restart:
	       cd $APP_DIR
	       sed -i '/^ADMIN_PASS/d' $APP_DIR/.env
	       docker compose up -d --force-recreate kimai
	     Recreating the container replaces the log file that held it. Confirm
	     with: docker compose logs kimai | grep -c 'kimai:user:create'
	     That must print 0. Your password manager is then the only copy;
	     docker compose exec -it kimai /opt/kimai/bin/console kimai:user:password admin
	     sets a new one if you lose it.
	  3. First backup written to $APP_DIR/backups: a database dump and a file
	     archive holding compose.yml, .env, var and the Caddy site block. They
	     are on the same disk as the data, which is not a backup. Copy them off
	     the box tonight.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/toggl-track/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
