Why Self-Host Kimai?
A time tracker holds sensitive data: who works for which customer, for how many hours, at what rate. On a SaaS platform that information lives with the vendor, and the bill grows with every seat you add. Hosting Kimai on your own VPS keeps the database within reach, lets you add users without licence fees, and puts you in charge of when upgrades happen.
- MIT licence — no licence fees and no user cap imposed by the vendor.
- Built-in invoicing — invoices are generated straight from timesheets, no external module needed.
- Documented REST API — connect your internal tools (ticketing, HR, dashboards) to your own data.
- Data sovereignty — hours, rates and customer records stay in your MySQL database.
- Lightweight stack — one application container plus a MySQL database, comfortable on an entry-level VPS.
Architecture: What Runs on the VPS
Kimai is a PHP application built on the Symfony framework. Version 2 relies on MySQL or MariaDB; SQLite is no longer supported. The official kimai/kimai2 image comes in two flavours: apache (bundled web server, listening on port 8001) and fpm (meant to sit behind your own Nginx). The ServOrbit template uses kimai/kimai2:apache-latest with MySQL 8.0, so the whole stack is just two containers.
The building blocks
kimaicontainer — Apache and PHP, the Symfony application in/opt/kimai, port 8001 published on127.0.0.1only.dbcontainer — MySQL 8.0 with amysqladmin pinghealthcheck, so Kimai only starts once the database is ready.kimai_dbvolume — the MySQL data files, in other words every timesheet you record.kimai_datavolume —/opt/kimai/var/data, files uploaded through the application (invoice templates, documents).kimai_publicvolume —/opt/kimai/public/avatars, user avatars.- Reverse proxy — Nginx, Caddy or Traefik terminates HTTPS and forwards requests to local port 8001.
Deploy Kimai on ServOrbit
The ServOrbit Marketplace provisions the full stack on Ubuntu 24.04: Docker, MySQL 8.0, the Kimai container, the three volumes and a generated administrator password. You do not need a domain name to get started.
Start the deployment
In your client area: Marketplace → ERP & Business Management → Kimai → Deploy. ServOrbit creates the VPS, installs Docker and starts the stack (MySQL 8 + Apache/PHP) automatically.
Collect your credentials
Wait about 60 seconds. The app card shows your credentials: login
[email protected]and a generated password.Connect without a domain
Open an SSH tunnel:
ssh -L 8080:127.0.0.1:8001 root@<ip>, then browse tohttp://localhost:8080. Add a domain from the dashboard whenever you are ready.Create a customer and a project
Create your first customer, then a project linked to it, and at least one activity. Set an hourly rate or a budget.
Log your first hours
Click ▶ to start the timer and pick the project and activity: Kimai records your time as you work.
On first login, open your profile to replace [email protected] with a real address and change the generated password. Then keep that super-admin account for administration only and create named accounts for day-to-day work.
Manual Installation with Docker Compose
If you would rather install Kimai yourself on an existing VPS, here is a docker-compose.yml equivalent to the template, with one extra volume for plugins. Put it in a dedicated directory such as /opt/kimai.
services:
db:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_DATABASE: kimai
MYSQL_USER: kimai
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- kimai_db:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
interval: 10s
retries: 12
kimai:
image: kimai/kimai2:apache-latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:8001:8001"
environment:
DATABASE_URL: "mysql://kimai:${DB_PASSWORD}@db/kimai?charset=utf8mb4&serverVersion=8.0.34"
APP_SECRET: ${APP_SECRET}
ADMINMAIL: [email protected]
ADMINPASS: ${ADMIN_PASSWORD}
TRUSTED_HOSTS: "localhost,127.0.0.1,kimai.your-domain.com"
volumes:
- kimai_data:/opt/kimai/var/data
- kimai_plugins:/opt/kimai/var/plugins
- kimai_public:/opt/kimai/public/avatars
volumes:
kimai_db:
kimai_data:
kimai_plugins:
kimai_public:Next, generate the secrets into a .env file, which Compose reads automatically, and start the stack. On first boot the container creates the database schema and the administrator account defined by ADMINMAIL and ADMINPASS.
cat > .env <<EOF
DB_PASSWORD=$(openssl rand -hex 16)
DB_ROOT_PASSWORD=$(openssl rand -hex 16)
APP_SECRET=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -hex 12)
EOF
chmod 600 .env
docker compose up -d
docker compose logs -f kimaiEnvironment variables worth knowing
DATABASE_URL— the Doctrine connection string: user, password, hostdb, database name,charset=utf8mb4andserverVersion. Because the password sits inside a URL, avoid@,/or:in it; a hexadecimal secret sidesteps the problem.APP_SECRET— the Symfony key used to sign sessions and tokens. Use a long random value, generate it once and leave it alone: changing it logs everyone out.ADMINMAILandADMINPASS— the super-admin account created on first boot. Once the instance is running, manage accounts from the UI or the console.TRUSTED_HOSTS— a comma-separated list of accepted host names. It must include your public domain, otherwise Symfony rejects the requests.MAILER_URL— the mail transport. The ServOrbit template sets it tonull://localhost, so no email is sent. Point it at an SMTP server to enable password resets and notifications.
Put Kimai Behind a Domain with HTTPS
Point the DNS
Create an A record for
kimai.your-domain.compointing at the VPS public IP, and check propagation withdig +short kimai.your-domain.com.Configure the reverse proxy
Port 8001 only listens on
127.0.0.1, so only the local proxy can reach it. A sample Nginx block:server { listen 80; server_name kimai.your-domain.com; location / { proxy_pass http://127.0.0.1:8001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }Get the certificate
With certbot and its Nginx plugin, the Let's Encrypt certificate is issued and the
serverblock is extended for port 443:apt install -y certbot python3-certbot-nginx certbot --nginx -d kimai.your-domain.comTell Kimai about the domain
Add
kimai.your-domain.comtoTRUSTED_HOSTS, then rundocker compose up -dto recreate the container. If generated links still usehttp://, read the Kimai documentation on reverse proxies:X-Forwarded-*headers are only honoured when they come from a trusted proxy.Close what you do not need
Open only ports 22, 80 and 443 in the firewall. MySQL publishes no port and should never have one.
Create Users from the Command Line
The web UI is enough for daily account management, but the Kimai Symfony console is invaluable for scripted onboarding or for regaining access. The kimai:user:create command takes a username, an email address and a role, then prompts for the password:
docker compose exec kimai /opt/kimai/bin/console kimai:user:create alice [email protected] ROLE_TEAMLEADThe same console offers kimai:reload to rebuild the cache after a configuration change, and kimai:update to run database migrations on installations outside Docker. Run bin/console list kimai inside the container to see the commands available in your version.
The Data Model: Customers, Projects, Activities
Kimai organises time on three levels. A customer carries the currency, the time zone and the billing details. A project belongs to a customer and can have start and end dates, an order number and a budget. An activity describes the kind of work (development, meeting, support); it can be attached to a single project or be global, available everywhere. Each timesheet entry ties together a user, a project, an activity, a time range and, if needed, a description and tags. Getting this hierarchy right from the start saves painful clean-ups later: one customer per billed entity, one project per engagement or contract, and global activities for cross-cutting work.
Rates and budgets
- Hourly rate — can be set on the customer, project, activity or user; the most specific level applies to each entry.
- Fixed rate — a flat amount per entry, handy for a job billed at a set price regardless of duration.
- Internal rate — what an hour actually costs you, kept separate from the billed price so you can measure project profitability.
- Time or money budget — on a customer, project or activity, with a visual consumption indicator.
- Monthly or total budget — a monthly maintenance retainer is tracked differently from a fixed-price project over its whole lifetime.
- Overruns — dashboards show how much of the budget is used, and a configuration setting lets you allow or block entries beyond it.
Roles, Teams and Permissions
Kimai uses hierarchical roles, and the Roles and permissions screen lets you fine-tune what each role can see and change. Teams complete the picture: a customer, project or activity restricted to a team is only visible to its members, and the team lead can review and adjust the hours of their team without seeing the rest of the company. For an outside contractor, create a user account limited to a dedicated team.
ROLE_USER— records and reviews their own hours on the projects they can access.ROLE_TEAMLEAD— sees and edits the hours of their team members and reads their reports.ROLE_ADMIN— manages customers, projects, activities, invoices and users.ROLE_SUPER_ADMIN— full access, including system configuration and permissions.
Invoicing from Timesheets
The invoicing module turns a selection of hours into a document. From the Invoices menu you pick a customer, a period, optionally a project, and then a template. Kimai computes the amounts from the rates, shows a preview and stores the invoice with a status (pending, paid, cancelled). Billed entries can be marked as exported, which prevents double billing and protects them from later edits.
Set Up Your First Invoice Template
Create the template
Under Invoices → Templates, enter the issuer, address, bank details, payment terms and footer text.
Choose the output format
Pick a PDF or HTML renderer for a ready-to-send document, or an office format if you finish the invoice in another tool.
Define the numbering
Configure the invoice number format (prefix, year, counter) to get a continuous sequence that matches your accounting habits.
Set the tax
Enter the applicable tax rate in the template; create one template per tax regime if you bill customers at different rates.
VAT and mandatory mentions
Kimai computes tax from the rate entered in the template, but it knows nothing about your tax situation. Legal mentions, the applicable rate and invoice retention rules depend on your country and status, so have your accountant review the template. If you keep full books in an ERP such as Dolibarr, Kimai can remain the time-capture tool and its export can feed the final invoice.
REST API and Integrations
Kimai exposes a REST API covering customers, projects, activities, timesheets and users. Its interactive documentation is served by your own instance at /api/doc, always in sync with the installed version. To authenticate a script, create an API token from your user profile and send it as a header:
curl -s -H "Authorization: Bearer YOUR_TOKEN" \
https://kimai.your-domain.com/api/timesheetsA token inherits its user's permissions, so create a service account with the minimum role required instead of using the super-admin. The API lets you start a timer from a shortcut, feed hours into a dashboard, or sync projects with your ticketing tool.
Extend Kimai with Plugins
Kimai has a plugin store on its official website, with free and paid extensions: expenses, custom fields, timesheet approval workflows, imports from other tools. A plugin is a directory placed in var/plugins/. With Docker, mount that directory on a volume, or it disappears whenever the container is recreated — that is what kimai_plugins does in the compose file above. A typical installation:
docker compose cp PluginNameBundle kimai:/opt/kimai/var/plugins/
docker compose exec kimai /opt/kimai/bin/console kimai:reload --env=prodSome plugins add database tables and ship their own install command; follow the plugin page and check compatibility with your Kimai version before every upgrade.
Backup and Restore
Almost everything lives in the MySQL database; the kimai_data and kimai_public volumes complete the backup. A logical dump is more portable than a hot copy of the MySQL files:
docker compose exec -T db sh -c 'exec mysqldump --single-transaction -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' > kimai-$(date +%F).sql
docker run --rm -v kimai_kimai_data:/data -v "$PWD":/backup alpine tar czf /backup/kimai_data-$(date +%F).tgz -C /data .The actual volume name is prefixed with the Compose project name, so check it with docker volume ls. To restore, start an empty stack with the same .env, then load the dump back:
docker compose exec -T db sh -c 'exec mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' < kimai-YYYY-MM-DD.sqlSchedule the dump nightly with cron, copy it off the VPS (Restic, BorgBackup or object storage) and test a restore at least once: a backup that has never been restored is only a hypothesis.
Upgrades without surprises
- Back up first — a dump taken right before the upgrade lets you roll back if a migration fails.
- Update the image —
docker compose pull kimai && docker compose up -d kimai, then followdocker compose logs -f kimai. On startup the container applies database migrations automatically. - Pin a version — in production, replace
apache-latestwith a versioned image tag so you decide when to upgrade. - Read the release notes — some major versions change requirements or plugin behaviour.
- Export regularly — a CSV or XLSX export of timesheets is a readable copy, useful for an audit or a migration.
Common Troubleshooting
Most Kimai-on-Docker incidents come down to a handful of known causes. Always start with docker compose logs kimai and docker compose ps.
- "Untrusted Host" or a 400 error after adding a domain — the host name is missing from
TRUSTED_HOSTS. Add it and recreate the container. - 500 error and "Permission denied" on
var/— often after a console command run as a different user from the web server. Restore ownership withdocker compose exec kimai chown -R www-data:www-data /opt/kimai/var, then runkimai:reload. - Configuration change has no effect — the Symfony cache is serving the old version:
docker compose exec kimai /opt/kimai/bin/console kimai:reload --env=prod. - Kimai keeps restarting on first boot — the database is not ready or
DATABASE_URLis wrong. Check thedbhealthcheck and the credentials in.env. http://links behind the HTTPS proxy —X-Forwarded-Protoheaders are not sent or not trusted; review the proxy configuration.- Forgotten password with no email configured —
MAILER_URLpoints tonull://: reset the password from an admin account or create a new administrator from the console.
Kimai vs Toggl Track, Clockify and Harvest
Scroll the table
| Criterion | Kimai (self-hosted) | Toggl Track | Clockify | Harvest |
|---|---|---|---|---|
| Licence | Open source (MIT) | Proprietary | Proprietary | Proprietary |
| Hosting | Your VPS | Vendor SaaS | Vendor SaaS | Vendor SaaS |
| Where data lives | Your MySQL database | Vendor infrastructure | Vendor infrastructure | Vendor infrastructure |
| Cost model | Server only, no per-user fee | Limited free tier, then subscription | Free tier, then subscription | Limited free tier, then subscription |
| Invoicing | Built-in module (templates, numbering, tax) | Depends on plan | Depends on plan | Built into the product |
| API | REST, tokens, bundled docs | REST | REST | REST |
| Customisation | Source code, plugins, configuration | Settings and integrations | Settings and integrations | Settings and integrations |
Sizing Your VPS
Kimai is frugal: most of the load comes from MySQL and from reports over long periods. For a freelancer or a small team, 1 vCPU and 1 to 2 GB of RAM is enough. Beyond roughly twenty active users, or if you regularly produce yearly reports and large exports, plan for 2 vCPUs and 4 GB, which leaves headroom for the MySQL InnoDB cache. Disk usage stays modest: a few gigabytes cover years of timesheets, but keep room for local dumps before they are shipped off the server.
Before inviting your team, set the ground rules under Settings: duration rounding, whether future entries are allowed, and locking of periods that have already been billed. These choices are far easier to make on an empty instance than on months of history.