OS Time Trackers: EigenFocus and Kimai

Published: Jan 23, 2025 by Isaac Johnson

Today we’ll dig into two very good containerized Open-Source time tracking apps. The first, EigenFocus I found by way of a Marius article and is a very clean and simple app a person could use to track time spent on various activities.

The second, a bit more full featured, in Kimai which is fully open-source but also has a paid cloud hosted version for those that don’t want to self host (at 3-4 Euro/month, it’s pretty reasonable).

EigenFocus

We can launch Eigenfocus using Docker by way of the steps listed in Github

docker run \
    --restart unless-stopped \
    -v ./app-data:/eigenfocus-app/app-data \
    -p 3001:3000 \
    -e DEFAULT_HOST_URL=http://localhost:3001 \
    -d \
    eigenfocus/eigenfocus:0.6.0

Let’s convert that over to a Kubernetes Manifest

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: eigenfocus-app-data-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: eigenfocus-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: eigenfocus
  template:
    metadata:
      labels:
        app: eigenfocus
    spec:
      containers:
      - name: eigenfocus
        image: eigenfocus/eigenfocus:0.6.0
        ports:
        - containerPort: 3000
        env:
        - name: DEFAULT_HOST_URL
          value: "http://localhost:3000"
        volumeMounts:
        - name: app-data-volume
          mountPath: /eigenfocus-app/app-data
      volumes:
      - name: app-data-volume
        persistentVolumeClaim:
          claimName: eigenfocus-app-data-pvc
---
apiVersion: v1
kind: Service
metadata:
  name: eigenfocus-service
spec:
  selector:
    app: eigenfocus
  ports:
  - protocol: TCP
    port: 80
    targetPort: 3000
  type: ClusterIP

I can now apply it

$ kubectl apply -f ./eigenfocus.yaml
persistentvolumeclaim/eigenfocus-app-data-pvc created
deployment.apps/eigenfocus-deployment created
service/eigenfocus-service created

Once we see the pod is up

$ kubectl get deployment eigenfocus-deployment
NAME                    READY   UP-TO-DATE   AVAILABLE   AGE
eigenfocus-deployment   1/1     1            1           66s

Let’s do a port forward test

$ kubectl get deployment eigenfocus-deployment
NAME                    READY   UP-TO-DATE   AVAILABLE   AGE
eigenfocus-deployment   1/1     1            1           66s
$ kubectl get pods | grep eigen
eigenfocus-deployment-8699f9f986-lm8n5               1/1     Running     0                98s
$ kubectl port-forward eigenfocus-deployment-8699f9f986-lm8n5 3000:3000
Forwarding from 127.0.0.1:3000 -> 3000
Forwarding from [::1]:3000 -> 3000

My first step is to set a timezone and language

/content/images/2025/01/eigenfocus-01.png

I’m then prompted with the getting started page

/content/images/2025/01/eigenfocus-02.png

Let’s start by creating a new project

/content/images/2025/01/eigenfocus-03.png

I’ll call it “FreshBrewed”

/content/images/2025/01/eigenfocus-04.png

I can now see the new project

/content/images/2025/01/eigenfocus-05.png

I went to the Board view and added a column

/content/images/2025/01/eigenfocus-06.png

I can enter a new task

/content/images/2025/01/eigenfocus-07.png

It’s easy to add new cards, columns and re-order them:

Time entries

We can log time against any task

/content/images/2025/01/eigenfocus-08.png

I saw the time entry had a “start” button. I was not sure if this meant my logged time was planned time or that was completed time, so I ran a test:

Basically, when we add a time with an amount, that is what we have completed already. Pressing “start” lets us add to it.

Reports

We can go to Time Reports to generate a report for any date range we desire

/content/images/2025/01/eigenfocus-10.png

This gives us a nice Summary with totals

/content/images/2025/01/eigenfocus-11.png

I used to do some external writing projects and training courses and I would often need to track time spent. For my professional work, often it is asked of us how much time we devoted to key quarterly initiatives - this too could be useful on that account.

Themes

There are a few nice looking themes. Here is a quick tour of the four that are there today

Ingress

I now can create an Ingress using my domain in Azure DNS

$ az account set --subscription "Pay-As-You-Go" && az network dns record-set a add-record -g idjdnsrg -z tpk.pw -a 75.73.224.240 -n eigenfocus
{
  "ARecords": [
    {
      "ipv4Address": "75.73.224.240"
    }
  ],
  "TTL": 3600,
  "etag": "35e9821a-eec4-4445-84f8-9d3ff5490b7c",
  "fqdn": "eigenfocus.tpk.pw.",
  "id": "/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/idjdnsrg/providers/Microsoft.Network/dnszones/tpk.pw/A/eigenfocus",
  "name": "eigenfocus",
  "provisioningState": "Succeeded",
  "resourceGroup": "idjdnsrg",
  "targetResource": {},
  "trafficManagementProfile": {},
  "type": "Microsoft.Network/dnszones/A"
}

I’m going to need a username and password for authentication. As we will create a K8s secret, the value should be base64’ed in the YAML

$ echo 'builder:mypassword' | tr -d '\n' | base64
YnVpbGRlcjpteXBhc3N3b3Jk

I’ll create a new Ingress with a Basic Auth secret (builder:mypassword in base64) that can be used to auth our ingress

$ cat eigenfocus.ingress.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: eigenfocus-app-data-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: eigenfocus-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: eigenfocus
  template:
    metadata:
      labels:
        app: eigenfocus
    spec:
      containers:
      - name: eigenfocus
        image: eigenfocus/eigenfocus:0.6.0
        ports:
        - containerPort: 3000
        env:
        - name: DEFAULT_HOST_URL
          value: "https://eigenfocus.tpk.pw"
        volumeMounts:
        - name: app-data-volume
          mountPath: /eigenfocus-app/app-data
      volumes:
      - name: app-data-volume
        persistentVolumeClaim:
          claimName: eigenfocus-app-data-pvc
---
apiVersion: v1
kind: Service
metadata:
  name: eigenfocus-service
spec:
  selector:
    app: eigenfocus
  ports:
  - protocol: TCP
    port: 80
    targetPort: 3000
  type: ClusterIP
---
apiVersion: v1
kind: Secret
metadata:
  name: eigen-basic-auth
data:
  auth: YnVpbGRlcjpteXBhc3N3b3Jk
type: Opaque
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    nginx.ingress.kubernetes.io/auth-type: "basic"
    nginx.ingress.kubernetes.io/auth-secret: "eigen-basic-auth"
    nginx.ingress.kubernetes.io/auth-realm: "Authentication Required"
    cert-manager.io/cluster-issuer: azuredns-tpkpw
    ingress.kubernetes.io/ssl-redirect: "true"
    kubernetes.io/ingress.class: nginx
    kubernetes.io/tls-acme: "true"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
  name: eigenfocusingress
spec:
  rules:
  - host: eigenfocus.tpk.pw
    http:
      paths:
      - backend:
          service:
            name: eigenfocus-service
            port:
              number: 80
        path: /
        pathType: Prefix
  tls:
  - hosts:
    - eigenfocus.tpk.pw
    secretName: eigenfocus-tls

I can now apply and it should create the secret and the ingress with the AzureDNS cluster issuer. You will note how the deployment now says configured because I went and updated the DNS entry to match the new URL

$ kubectl apply -f ./eigenfocus.ingress.yaml
persistentvolumeclaim/eigenfocus-app-data-pvc unchanged
deployment.apps/eigenfocus-deployment configured
service/eigenfocus-service unchanged
secret/eigen-basic-auth created
ingress.networking.k8s.io/eigenfocusingress created

While this exposed the app, it did not prompt for auth

/content/images/2025/01/eigenfocus-12.png

$ sudo apt install apache2-utils Reading package lists… Done Building dependency tree… Done Reading state information… Done The following additional packages will be installed: libapr1t64 libaprutil1t64 The following NEW packages will be installed: apache2-utils libapr1t64 libaprutil1t64 0 upgraded, 3 newly installed, 0 to remove and 64 not upgraded. Need to get 297 kB of archives. After this operation, 907 kB of additional disk space will be used. Do you want to continue? [Y/n] y Get:1 http://archive.ubuntu.com/ubuntu noble-updates/main amd64 libapr1t64 amd64 1.7.2-3.1ubuntu0.1 [108 kB] Get:2 http://archive.ubuntu.com/ubuntu noble/main amd64 libaprutil1t64 amd64 1.6.3-1.1ubuntu7 [91.9 kB] Get:3 http://archive.ubuntu.com/ubuntu noble-updates/main amd64 apache2-utils amd64 2.4.58-1ubuntu8.5 [97.1 kB] Fetched 297 kB in 2s (145 kB/s) Selecting previously unselected package libapr1t64:amd64. (Reading database … 168813 files and directories currently installed.) Preparing to unpack …/libapr1t64_1.7.2-3.1ubuntu0.1_amd64.deb … Unpacking libapr1t64:amd64 (1.7.2-3.1ubuntu0.1) … Selecting previously unselected package libaprutil1t64:amd64. Preparing to unpack …/libaprutil1t64_1.6.3-1.1ubuntu7_amd64.deb … Unpacking libaprutil1t64:amd64 (1.6.3-1.1ubuntu7) … Selecting previously unselected package apache2-utils. Preparing to unpack …/apache2-utils_2.4.58-1ubuntu8.5_amd64.deb … Unpacking apache2-utils (2.4.58-1ubuntu8.5) … Setting up libapr1t64:amd64 (1.7.2-3.1ubuntu0.1) … Setting up libaprutil1t64:amd64 (1.6.3-1.1ubuntu7) … Setting up apache2-utils (2.4.58-1ubuntu8.5) … Processing triggers for man-db (2.12.0-4build2) … Processing triggers for libc-bin (2.39-0ubuntu8.3) …

Now I’ll try creating the entry with htpasswd instead

builder@LuiGi:~/Workspaces/jekyll-blog$ htpasswd -c auth builder
New password:
Re-type new password:
Adding password for user builder
builder@LuiGi:~/Workspaces/jekyll-blog$ kubectl create secret generic eigenfocus-basic-auth --from-file=auth
secret/eigenfocus-basic-auth created

I’ll change the ingress file to use that new secret

$ diff eigenfocus.ingress2.yaml eigenfocus.ingress.yaml
60c60
<   auth: YnVpbGRlcjpteXBhc3N3b3Jk  # base64-encoded "builder:mypassword"
---
>   auth: YnVpbGRlcjpteXBhc3N3b3Jk
66d65
<   name: eigenfocusingress
69c68
<     nginx.ingress.kubernetes.io/auth-secret: "eigenfocus-basic-auth"
---
>     nginx.ingress.kubernetes.io/auth-secret: "eigen-basic-auth"
76a76
>   name: eigenfocusingress
82,84c82
<       - path: /
<         pathType: Prefix
<         backend:
---
>       - backend:
88a87,88
>         path: /
>         pathType: Prefix

and try again

builder@LuiGi:~/Workspaces/jekyll-blog$ kubectl delete ingress eigenfocusingress
ingress.networking.k8s.io "eigenfocusingress" deleted
builder@LuiGi:~/Workspaces/jekyll-blog$ kubectl apply -f ./eigenfocus.ingress2.yaml
persistentvolumeclaim/eigenfocus-app-data-pvc unchanged
deployment.apps/eigenfocus-deployment unchanged
service/eigenfocus-service unchanged
secret/eigen-basic-auth unchanged
ingress.networking.k8s.io/eigenfocusingress created

But still no luck

/content/images/2025/01/eigenfocus-13.png

I even tried swapping it over to HTTP instead of HTTPS

/content/images/2025/01/eigenfocus-14.png

While it seems my Ingress Controller isn’t cooperating with the password authentication, perhaps there is a more low-tech way to solve this.

Let’s remove the ingress and existing ClusterIP service

builder@DESKTOP-QADGF36:~$ kubectl get svc eigenfocus-service -o yaml > eigenfocus.svc.yaml
builder@DESKTOP-QADGF36:~$ vi eigenfocus.svc.yaml
builder@DESKTOP-QADGF36:~$ kubectl delete svc eigenfocus-service
service "eigenfocus-service" deleted

Now add it as a NodePort service instead

builder@DESKTOP-QADGF36:~$ cat eigenfocus.svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: eigenfocus-service
spec:
  ports:
  - port: 80
    protocol: TCP
    targetPort: 3000
  selector:
    app: eigenfocus
  type: NodePort
builder@DESKTOP-QADGF36:~$ kubectl apply -f ./eigenfocus.svc.yaml
service/eigenfocus-service created

I can now see the Node Port used for this service:

$ kubectl get svc eigenfocus-service
NAME                 TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
eigenfocus-service   NodePort   10.43.250.47   <none>        80:32201/TCP   42s

I can use a jump box like Obsidian to access it on that port remotely. The nice thing about this Obsidian instance, is I really don’t use it for that app, rather as a GUI’ed endpoint for internal apps. If you want to see how I set that up, you can read more in the Obsidian and Timesy app article here.

To finish that up, I’ll want to set the URL properly in the deployment

And of course I can access it directly in my network

/content/images/2025/01/eigenfocus-16.png

Kimai

Kimai is an Open-Source time tracking app that is easy to install locally

We could start with Docker, but let’s just jump right in with their Kubernetes Helm chart

I first will add the Chart repo

$ helm repo add robjuz https://robjuz.github.io/helm-charts/
"robjuz" has been added to your repositories

Then use it to install Kimai

$ helm install kimai --set kimaiAppSecret=DKenCIKdoe830kdfc754kcme89 --set kimaiAdminEmail=isaac.johnson@gmail.com --set kimaiAdminPassword=
MyPasswordForAdmin2quitNow --set kimaiMailerFrom=isaac@freshbrewed.science --set kimaiMailerUrl=smtps://apikey:
SG.asdfjkasdf843jd8-asdfi3kjJCXHF390DIKfh-aasdf
gOC9pDIb5HMM48@smtp.sendgrid.net:465 robjuz/kimai2
NAME: kimai
LAST DEPLOYED: Wed Jan 22 05:53:31 2025
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
CHART NAME: kimai2
CHART VERSION: 4.3.1
APP VERSION: apache-2.27.0

** Please be patient while the chart is being deployed **

Your Kimai instance can be accessed through the following DNS name from within your cluster:

    kimai-kimai2.default.svc.cluster.local (port 80)

To access your Kimai instance from outside the cluster follow the steps below:

1. Get the Kimai URL by running these commands:

  NOTE: It may take a few minutes for the LoadBalancer IP to be available.
        Watch the status with: 'kubectl get svc --namespace default -w kimai-kimai2'

   export SERVICE_IP=$(kubectl get svc --namespace default kimai-kimai2 --template "{{ range (index .status.loadBalancer.ingress 0) }}{{ . }}{{ end }}")
   echo "Kimai URL: http://$SERVICE_IP/"

2. Open a browser and access Kimai using the obtained URL.

3. Login with the following credentials below to see your blog:

  echo Username: isaac.johnson@gmail.com
  echo Password: $(kubectl get secret --namespace default kimai-kimai2 -o jsonpath="{.data.admin-password}" | base64 -d)

In this case, my SendGrid password is SG.asdfjkasdf843jd8-asdfi3kjJCXHF390DIKfh-aasdf. It will always use apikey as the user. I also set the admin password to ` MyPasswordForAdmin2quitNow`

I watched for the pods to come up

$ kubectl get po | grep kimai
kimai-kimai2-57d8f69c64-7rcjj                        0/1     ContainerCreating   0                19s
kimai-mariadb-0                                      0/1     Running             0                19s

It took just under 2m to see the full stack come up on my cluster

$ kubectl get po | grep kimai
kimai-mariadb-0                                      1/1     Running     0                110s
kimai-kimai2-57d8f69c64-7rcjj                        1/1     Running     0                110s

I realized I didn’t override the service type to ClusterIP, but I can circle back on that when we do Ingress

$ kubectl get svc | grep kimai
kimai-mariadb                                           ClusterIP      10.43.51.224    <none>                                                 3306/TCP                                                  81s
kimai-kimai2                                            LoadBalancer   10.43.30.235    <pending>                                              80:31553/TCP                                              81s

To test, I’ll port-forward to the service

$ kubectl port-forward svc/kimai-kimai2 8088:80
Forwarding from 127.0.0.1:8088 -> 8001
Forwarding from [::1]:8088 -> 8001

/content/images/2025/01/kimai-01.png

I know the password because I set it in the helm invokation, but in case we forget, we can fetch from k8s

$ kubectl get secret --namespace default kimai-kimai2 -o jsonpath="{.data.admin-password}" | base64 -d

MyPasswordForAdmin2quitNow

I now get a welcome splash

/content/images/2025/01/kimai-02.png

that takes me to a setup screen

/content/images/2025/01/kimai-03.png

I get a Congrats page

/content/images/2025/01/kimai-04.png

Before landing on the main page

/content/images/2025/01/kimai-05.png

Let’s set up our first Customer, Project, and Activity. Then Enter our first time entry

Here you can see a report of billed hours thus far

/content/images/2025/01/kimai-07.png

I can click the Start button to start a new activity entry

/content/images/2025/01/kimai-08.png

I now see a minute counter in the upper right tracking time. It keeps the counter in the title and the upper right

/content/images/2025/01/kimai-09.png

Let’s create a team

/content/images/2025/01/kimai-10.png

I can then give the team a name and colour as well as add members

/content/images/2025/01/kimai-11.png

I can then pick which customers and projects to which this team has access

/content/images/2025/01/kimai-12.png

Reports

Let’s go to reporting and see what formats to which we can export

/content/images/2025/01/kimai-13.png

By default, I see all entries and it does not include the in-progress time (the timer is still ticking in my title bar)

/content/images/2025/01/kimai-14.png

The filter can thin the list to just those matching a time rang, or project or activity amongst other criteria

/content/images/2025/01/kimai-15.png

For instance, I did a “Print” report for ‘self’ for just this week

/content/images/2025/01/kimai-16.png

When I click the “stop” icon, I can see my entry is saved

/content/images/2025/01/kimai-17.png

Now I see those accounted for in the total for the week

/content/images/2025/01/kimai-18.png

Ingress

Let’s try to actually use Helm to expose this (I usually avoid Helm, but let’s try it)

I’ll fire up an Azure DNS entry

$ az account set --subscription "Pay-As-You-Go" && az network dns record-set a add-record -g idjdnsrg -z tpk.pw -a 75.73.224.240 -n kimai
{
  "ARecords": [
    {
      "ipv4Address": "75.73.224.240"
    }
  ],
  "TTL": 3600,
  "etag": "67a744f9-b106-499f-9caf-3efe5eaca295",
  "fqdn": "kimai.tpk.pw.",
  "id": "/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/idjdnsrg/providers/Microsoft.Network/dnszones/tpk.pw/A/kimai",
  "name": "kimai",
  "provisioningState": "Succeeded",
  "resourceGroup": "idjdnsrg",
  "targetResource": {},
  "trafficManagementProfile": {},
  "type": "Microsoft.Network/dnszones/A"
}

Now the key annotations that matter are using the right Cluster Issuer (I have 3) and ingress class name

cert-manager.io/cluster-issuer: azuredns-tpkpw
kubernetes.io/ingress.class: nginx

I tried three ways to get the annotations to take, the third seems to have worked

builder@DESKTOP-QADGF36:~/Workspaces/subscription-manager2$ helm upgrade kimai --set service.type=ClusterIP --set ingress.enabled=true --set ingress.ingressClassName=nginx --set ingress.hostname=kimai.tpk.pw --set ingress.tls=true --set ingress.annotations.cert-manager.io/cluster-issuer=azuredns-tpkpw --set kimaiAppSecret=DKenCIKdoe830kdfc754kcme89 --set kimaiAdminEmail=isaac.johnson@gmail.com --set kimaiAdminPassword=
MyPasswordForAdmin2quitNow --set kimaiMailerFrom=isaac@freshbrewed.science --set kimaiMailerUrl=smtps://apikey:
SG.asdfjkasdf843jd8-asdfi3kjJCXHF390DIKfh-aasdfgOC9pDIb5HMM48@smtp.sendgrid.net:465 robjuz/kimai2
Error: UPGRADE FAILED: YAML parse error on kimai2/templates/ingress.yaml: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal object into Go struct field .metadata.annotations of type string
builder@DESKTOP-QADGF36:~/Workspaces/subscription-manager2$
builder@DESKTOP-QADGF36:~/Workspaces/subscription-manager2$
builder@DESKTOP-QADGF36:~/Workspaces/subscription-manager2$ helm upgrade kimai --set service.type=ClusterIP --set ingress.enabled=true --set ingress.ingressClassName=nginx --set ingress.hostname=kimai.tpk.pw --set ingress.tls=true --set ingress.annotations[0].key="cert-manager.io/cluster-issuer" --set ingress.annotations[0].value="azuredns-tpkpw" --set kimaiAppSecret=DKenCIKdoe830kdfc754kcme89 --set kimaiAdminEmail=isaac.johnson@gmail.com --set kimaiAdminPassword=
MyPasswordForAdmin2quitNow --set kimaiMailerFrom=isaac@freshbrewed.science --set kimaiMailerUrl=smtps://apikey:
SG.asdfjkasdf843jd8-asdfi3kjJCXHF390DIKfh-aasdfgOC9pDIb5HMM48@smtp.sendgrid.net:465 robjuz/kimai2
coalesce.go:220: warning: cannot overwrite table with non table for kimai2.ingress.annotations (map[])
coalesce.go:220: warning: cannot overwrite table with non table for kimai2.ingress.annotations (map[])
Error: UPGRADE FAILED: template: kimai2/templates/ingress.yaml:49:42: executing "kimai2/templates/ingress.yaml" at <include "common.ingress.certManagerRequest" (dict "annotations" .Values.ingress.annotations)>: error calling include: template: kimai2/charts/common/templates/_ingress.tpl:70:17: executing "common.ingress.certManagerRequest" at <.annotations>: wrong type for value; expected map[string]interface {}; got []interface {}
builder@DESKTOP-QADGF36:~/Workspaces/subscription-manager2$
builder@DESKTOP-QADGF36:~/Workspaces/subscription-manager2$
builder@DESKTOP-QADGF36:~/Workspaces/subscription-manager2$ helm upgrade kimai --set service.type=ClusterIP --set ingress.enabled=true --set ingress.ingressClassName=nginx --set ingress.hostname=kimai.tpk.pw --set ingress.tls=true --set ingress.annotations.'cert-manager\.io/cluster-issuer'=azuredns-tpkpw --set kimaiAppSecret=DKenCIKdoe830kdfc754kcme89 --set kimaiAdminEmail=isaac.johnson@gmail.com --set kimaiAdminPassword=
MyPasswordForAdmin2quitNow --set kimaiMailerFrom=isaac@freshbrewed.science --set kimaiMailerUrl=smtps://apikey:
SG.asdfjkasdf843jd8-asdfi3kjJCXHF390DIKfh-aasdfgOC9pDIb5HMM48@smtp.sendgrid.net:465 robjuz/kimai2
Release "kimai" has been upgraded. Happy Helming!
NAME: kimai
LAST DEPLOYED: Wed Jan 22 06:31:26 2025
NAMESPACE: default
STATUS: deployed
REVISION: 2
TEST SUITE: None
NOTES:
CHART NAME: kimai2
CHART VERSION: 4.3.1
APP VERSION: apache-2.27.0

** Please be patient while the chart is being deployed **

Your Kimai instance can be accessed through the following DNS name from within your cluster:

    kimai-kimai2.default.svc.cluster.local (port 80)

To access your Kimai instance from outside the cluster follow the steps below:

1. Get the Kimai URL and associate Kimai hostname to your cluster external IP:

   export CLUSTER_IP=$(minikube ip) # On Minikube. Use: `kubectl cluster-info` on others K8s clusters
   echo "Kimai URL: https://kimai.tpk.pw/"
   echo "$CLUSTER_IP  kimai.tpk.pw" | sudo tee -a /etc/hosts

2. Open a browser and access Kimai using the obtained URL.

3. Login with the following credentials below to see your blog:

  echo Username: isaac.johnson@gmail.com
  echo Password: $(kubectl get secret --namespace default kimai-kimai2 -o jsonpath="{.data.admin-password}" | base64 -d)

I checked, and it looks correct

$ kubectl get ingress kimai-kimai2 -o yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    cert-manager.io/cluster-issuer: azuredns-tpkpw
    meta.helm.sh/release-name: kimai
    meta.helm.sh/release-namespace: default
  creationTimestamp: "2025-01-22T12:31:30Z"
  generation: 1
  labels:
    app.kubernetes.io/instance: kimai
    app.kubernetes.io/managed-by: Helm
    app.kubernetes.io/name: kimai2
    app.kubernetes.io/version: apache-2.27.0
    helm.sh/chart: kimai2-4.3.1
  name: kimai-kimai2
  namespace: default
  resourceVersion: "53142336"
  uid: 0971514f-4741-4f0f-8e19-6b1d3b62e51d
spec:
  ingressClassName: nginx
  rules:
  - host: kimai.tpk.pw
    http:
      paths:
      - backend:
          service:
            name: kimai-kimai2
            port:
              name: http
        path: /
        pathType: ImplementationSpecific
  tls:
  - hosts:
    - kimai.tpk.pw
    secretName: kimai.tpk.pw-tls
status:
  loadBalancer: {}

When I see the cert satisified

$ kubectl get cert kimai.tpk.pw-tls
NAME               READY   SECRET             AGE
kimai.tpk.pw-tls   True    kimai.tpk.pw-tls   91s

I can try the URL

/content/images/2025/01/kimai-19.png

Once logged in, I can see it persisted my entries

/content/images/2025/01/kimai-20.png

Calender

We can also use the Calendar view to see our entries

/content/images/2025/01/kimai-21.png

Which includes a weekly break down

/content/images/2025/01/kimai-22.png

Plugins

There is a pretty expansive list of free and paid plugins we can use to extend Kimai

/content/images/2025/01/kimai-23.png

Let’s add Timesheet Approvals

/content/images/2025/01/kimai-24.png

If we look at the linked Github page with install instructions

we can see that the zip needs to get into the ‘var/plugins’ folder. This might be a bit tricky with a containerized app

As we can see in the chart docs, we can use a volume mount (which should stay over a pod rotate)

/content/images/2025/01/kimai-25.png

At this point our helm set values are getting a bit much, so i’ll switch to a values file for this

$ helm get values --all kimai -o yaml > kimai.values.yaml
$ helm get values --all kimai -o yaml > kimai.values.yaml.bak

I’ll then just add the extraVolumeMounts as described

$ diff kimai.values.yaml kimai.values.yaml.bak
71,74c71
< extraVolumeMounts:
<     - mountPath: /opt/kimai/var/plugins
<       name: kimai-data
<       subPath: plugins
---
> extraVolumeMounts: []

Then use the new values

$ helm upgrade kimai -f kimai.values.yaml robjuz/kimai2
Release "kimai" has been upgraded. Happy Helming!
NAME: kimai
LAST DEPLOYED: Wed Jan 22 06:45:59 2025
NAMESPACE: default
STATUS: deployed
REVISION: 3
TEST SUITE: None
NOTES:
CHART NAME: kimai2
CHART VERSION: 4.3.1
APP VERSION: apache-2.27.0

** Please be patient while the chart is being deployed **

Your Kimai instance can be accessed through the following DNS name from within your cluster:

    kimai-kimai2.default.svc.cluster.local (port 80)

To access your Kimai instance from outside the cluster follow the steps below:

1. Get the Kimai URL and associate Kimai hostname to your cluster external IP:

   export CLUSTER_IP=$(minikube ip) # On Minikube. Use: `kubectl cluster-info` on others K8s clusters
   echo "Kimai URL: https://kimai.tpk.pw/"
   echo "$CLUSTER_IP  kimai.tpk.pw" | sudo tee -a /etc/hosts

2. Open a browser and access Kimai using the obtained URL.

3. Login with the following credentials below to see your blog:

  echo Username: isaac.johnson@gmail.com
  echo Password: $(kubectl get secret --namespace default kimai-kimai2 -o jsonpath="{.data.admin-password}" | base64 -d)

Once the new pod comes into play

$ kubectl get po  | grep kimai
kimai-mariadb-0                                      1/1     Running     0                  49m
kimai-kimai2-86f7864cd7-c845f                        1/1     Running     0                  15m
kimai-kimai2-587fb76f48-p67nz                        0/1     Running     0                  39s
$ kubectl get po  | grep kimai
kimai-mariadb-0                                      1/1     Running       0                  49m
kimai-kimai2-587fb76f48-p67nz                        1/1     Running       0                  44s
kimai-kimai2-86f7864cd7-c845f                        1/1     Terminating   0                  15m
$ kubectl get po  | grep kimai
kimai-mariadb-0                                      1/1     Running     0                  49m
kimai-kimai2-587fb76f48-p67nz                        1/1     Running     0                  47s

I can hop into the pod

$ kubectl exec -it kimai-kimai2-587fb76f48-p67nz -- /bin/bash
root@kimai-kimai2-587fb76f48-p67nz:/var/www/html#

I’ll want to get to the plugins dir and add wget so we can download the plugin zip

root@kimai-kimai2-587fb76f48-p67nz:/var/www/html# cd /opt/kimai/var/plugins
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# ls
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# which wget
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# apt update && apt install wget
Get:1 http://deb.debian.org/debian bookworm InRelease [151 kB]
Get:2 http://deb.debian.org/debian bookworm-updates InRelease [55.4 kB]
Get:3 http://deb.debian.org/debian-security bookworm-security InRelease [48.0 kB]
Get:4 http://deb.debian.org/debian bookworm/main amd64 Packages [8792 kB]
Get:5 http://deb.debian.org/debian bookworm-updates/main amd64 Packages.diff/Index [15.1 kB]
Get:6 http://deb.debian.org/debian bookworm-updates/main amd64 Packages T-2025-01-14-2009.05-F-2025-01-14-2009.05.pdiff [5693 B]
Get:6 http://deb.debian.org/debian bookworm-updates/main amd64 Packages T-2025-01-14-2009.05-F-2025-01-14-2009.05.pdiff [5693 B]
Get:7 http://deb.debian.org/debian-security bookworm-security/main amd64 Packages [241 kB]
Fetched 9309 kB in 2s (5423 kB/s)
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
12 packages can be upgraded. Run 'apt list --upgradable' to see them.
N: Repository 'http://deb.debian.org/debian bookworm InRelease' changed its 'Version' value from '12.8' to '12.9'
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
The following NEW packages will be installed:
  wget
0 upgraded, 1 newly installed, 0 to remove and 12 not upgraded.
Need to get 984 kB of archives.
After this operation, 3692 kB of additional disk space will be used.
Get:1 http://deb.debian.org/debian bookworm/main amd64 wget amd64 1.21.3-1+b2 [984 kB]
Fetched 984 kB in 0s (5559 kB/s)
debconf: delaying package configuration, since apt-utils is not installed
Selecting previously unselected package wget.
(Reading database ... 14244 files and directories currently installed.)
Preparing to unpack .../wget_1.21.3-1+b2_amd64.deb ...
Unpacking wget (1.21.3-1+b2) ...
Setting up wget (1.21.3-1+b2) ...
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins#

I can now download and unzip the ApprovalBundle

root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# wget https://github.com/KatjaGlassConsulting/ApprovalBundle/archive/refs/tags/2.2.0.zip
--2025-01-22 14:06:58--  https://github.com/KatjaGlassConsulting/ApprovalBundle/archive/refs/tags/2.2.0.zip
Resolving github.com (github.com)... 140.82.113.4
Connecting to github.com (github.com)|140.82.113.4|:443... connected.
HTTP request sent, awaiting response... 302 Found
Location: https://codeload.github.com/KatjaGlassConsulting/ApprovalBundle/zip/refs/tags/2.2.0 [following]
--2025-01-22 14:06:59--  https://codeload.github.com/KatjaGlassConsulting/ApprovalBundle/zip/refs/tags/2.2.0
Resolving codeload.github.com (codeload.github.com)... 140.82.114.10
Connecting to codeload.github.com (codeload.github.com)|140.82.114.10|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: unspecified [application/zip]
Saving to: '2.2.0.zip'

2.2.0.zip                                                  [  <=>                                                                                                                     ]   4.40M  12.2MB/s    in 0.4s

2025-01-22 14:06:59 (12.2 MB/s) - '2.2.0.zip' saved [4612676]

root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# unzip 2.2.0.zip
Archive:  2.2.0.zip
78fed5425bec247a2bbbe0e6a6da4dc9eea66790
   creating: ApprovalBundle-2.2.0/
   creating: ApprovalBundle-2.2.0/.github/
  inflating: ApprovalBundle-2.2.0/.github/FUNDING.yml
  inflating: ApprovalBundle-2.2.0/.github/linting.yaml
 extracting: ApprovalBundle-2.2.0/.gitignore
  inflating: ApprovalBundle-2.2.0/.php-cs-fixer.dist.php
   creating: ApprovalBundle-2.2.0/API/
  inflating: ApprovalBundle-2.2.0/API/ApprovalBundleApiController.php
  inflating: ApprovalBundle-2.2.0/API/ApprovalNextWeekApiController.php
  inflating: ApprovalBundle-2.2.0/API/ApprovalOvertimeController.php
  inflating: ApprovalBundle-2.2.0/API/ApprovalOvertimeWeeklyController.php
  inflating: ApprovalBundle-2.2.0/API/ApprovalStatusApiController.php
  inflating: ApprovalBundle-2.2.0/ApprovalBundle.php
  inflating: ApprovalBundle-2.2.0/CHANGELOG.md
   creating: ApprovalBundle-2.2.0/Command/
  inflating: ApprovalBundle-2.2.0/Command/AdminNotSubmittedCommand.php
  inflating: ApprovalBundle-2.2.0/Command/InstallCommand.php
  inflating: ApprovalBundle-2.2.0/Command/TeamleadNotSubmittedCommand.php
  inflating: ApprovalBundle-2.2.0/Command/UserNotSubmittedCommand.php
   creating: ApprovalBundle-2.2.0/Controller/
  inflating: ApprovalBundle-2.2.0/Controller/ApprovalController.php
  inflating: ApprovalBundle-2.2.0/Controller/BaseApprovalController.php
  inflating: ApprovalBundle-2.2.0/Controller/OvertimeAllReportController.php
  inflating: ApprovalBundle-2.2.0/Controller/OvertimeReportController.php
  inflating: ApprovalBundle-2.2.0/Controller/SettingsOvertimeController.php
  inflating: ApprovalBundle-2.2.0/Controller/WeekReportController.php
   creating: ApprovalBundle-2.2.0/DependencyInjection/
  inflating: ApprovalBundle-2.2.0/DependencyInjection/ApprovalExtension.php
   creating: ApprovalBundle-2.2.0/DependencyInjection/Compiler/
  inflating: ApprovalBundle-2.2.0/DependencyInjection/Compiler/ApprovalSettingsCompilerPass.php
   creating: ApprovalBundle-2.2.0/Entity/
  inflating: ApprovalBundle-2.2.0/Entity/Approval.php
  inflating: ApprovalBundle-2.2.0/Entity/ApprovalHistory.php
  inflating: ApprovalBundle-2.2.0/Entity/ApprovalOvertimeHistory.php
  inflating: ApprovalBundle-2.2.0/Entity/ApprovalStatus.php
  inflating: ApprovalBundle-2.2.0/Entity/ApprovalWorkdayHistory.php
   creating: ApprovalBundle-2.2.0/Enumeration/
  inflating: ApprovalBundle-2.2.0/Enumeration/ConfigEnum.php
  inflating: ApprovalBundle-2.2.0/Enumeration/FormEnum.php
   creating: ApprovalBundle-2.2.0/EventSubscriber/
  inflating: ApprovalBundle-2.2.0/EventSubscriber/MenuSubscriber.php
   creating: ApprovalBundle-2.2.0/Extension/
  inflating: ApprovalBundle-2.2.0/Extension/FormattingExtension.php
   creating: ApprovalBundle-2.2.0/Form/
  inflating: ApprovalBundle-2.2.0/Form/AddOvertimeHistoryForm.php
  inflating: ApprovalBundle-2.2.0/Form/AddToApprove.php
  inflating: ApprovalBundle-2.2.0/Form/AddWorkdayHistoryForm.php
  inflating: ApprovalBundle-2.2.0/Form/OvertimeByAllForm.php
  inflating: ApprovalBundle-2.2.0/Form/OvertimeByUserForm.php
  inflating: ApprovalBundle-2.2.0/Form/SettingsForm.php
  inflating: ApprovalBundle-2.2.0/Form/WeekByUserForm.php
  inflating: ApprovalBundle-2.2.0/LICENSE
   creating: ApprovalBundle-2.2.0/Migrations/
  inflating: ApprovalBundle-2.2.0/Migrations/Version20220208134542.php
  inflating: ApprovalBundle-2.2.0/Migrations/Version20220210154511.php
  inflating: ApprovalBundle-2.2.0/Migrations/Version20220303101010.php
  inflating: ApprovalBundle-2.2.0/Migrations/Version20220303134149.php
  inflating: ApprovalBundle-2.2.0/Migrations/Version20220307092555.php
  inflating: ApprovalBundle-2.2.0/Migrations/Version20220318122512.php
  inflating: ApprovalBundle-2.2.0/Migrations/Version20221118162725.php
  inflating: ApprovalBundle-2.2.0/Migrations/Version20231016134127.php
  inflating: ApprovalBundle-2.2.0/Migrations/Version20240828161654.php
  inflating: ApprovalBundle-2.2.0/Migrations/approval.yaml
  inflating: ApprovalBundle-2.2.0/README.md
   creating: ApprovalBundle-2.2.0/Repository/
  inflating: ApprovalBundle-2.2.0/Repository/ApprovalHistoryRepository.php
  inflating: ApprovalBundle-2.2.0/Repository/ApprovalOvertimeHistoryRepository.php
  inflating: ApprovalBundle-2.2.0/Repository/ApprovalRepository.php
  inflating: ApprovalBundle-2.2.0/Repository/ApprovalStatusRepository.php
  inflating: ApprovalBundle-2.2.0/Repository/ApprovalTimesheetRepository.php
  inflating: ApprovalBundle-2.2.0/Repository/ApprovalWorkdayHistoryRepository.php
  inflating: ApprovalBundle-2.2.0/Repository/LockdownRepository.php
  inflating: ApprovalBundle-2.2.0/Repository/ReportRepository.php
   creating: ApprovalBundle-2.2.0/Resources/
   creating: ApprovalBundle-2.2.0/Resources/config/
  inflating: ApprovalBundle-2.2.0/Resources/config/routes.yaml
  inflating: ApprovalBundle-2.2.0/Resources/config/services.yaml
   creating: ApprovalBundle-2.2.0/Resources/translations/
  inflating: ApprovalBundle-2.2.0/Resources/translations/messages.de.xlf
  inflating: ApprovalBundle-2.2.0/Resources/translations/messages.en.xlf
  inflating: ApprovalBundle-2.2.0/Resources/translations/messages.hr.xlf
   creating: ApprovalBundle-2.2.0/Resources/views/
  inflating: ApprovalBundle-2.2.0/Resources/views/add_overtime_history.html.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/add_to_approve.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/add_workday_history.html.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/approved.email.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/approvedChangeStatus.email.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/closedMonth.email.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/cronjob.adminNotSubmitted.email.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/cronjob.teamleadNotSubmittedUsers.email.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/cronjob.userNotSubmittedWeeks.email.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/layout.html.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/macros.html.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/navigation.html.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/overtime_by_all.html.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/overtime_by_user.html.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/report_by_user.html.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/settings.html.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/settings_overtime_history.html.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/settings_workday_history.html.twig
  inflating: ApprovalBundle-2.2.0/Resources/views/to_approve.html.twig
   creating: ApprovalBundle-2.2.0/Scripts/
  inflating: ApprovalBundle-2.2.0/Scripts/skr_kimai_approval_check.sh
   creating: ApprovalBundle-2.2.0/Settings/
  inflating: ApprovalBundle-2.2.0/Settings/ApprovalSettingsInterface.php
  inflating: ApprovalBundle-2.2.0/Settings/DefaultSettings.php
  inflating: ApprovalBundle-2.2.0/Settings/MetaFieldSettings.php
   creating: ApprovalBundle-2.2.0/Toolbox/
  inflating: ApprovalBundle-2.2.0/Toolbox/BreakTimeCheckToolGER.php
  inflating: ApprovalBundle-2.2.0/Toolbox/EmailTool.php
  inflating: ApprovalBundle-2.2.0/Toolbox/FormTool.php
  inflating: ApprovalBundle-2.2.0/Toolbox/Formatting.php
  inflating: ApprovalBundle-2.2.0/Toolbox/FormattingTool.php
  inflating: ApprovalBundle-2.2.0/Toolbox/SecurityTool.php
  inflating: ApprovalBundle-2.2.0/Toolbox/SettingsTool.php
   creating: ApprovalBundle-2.2.0/_documentation/
  inflating: ApprovalBundle-2.2.0/_documentation/ApprovalAdmin.gif
  inflating: ApprovalBundle-2.2.0/_documentation/ApprovalLockdown.png
  inflating: ApprovalBundle-2.2.0/_documentation/ApprovalTeamlead.gif
  inflating: ApprovalBundle-2.2.0/_documentation/ApprovalUser.gif
  inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_AdminRollbackOption.png
  inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_Settings.png
  inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_TeamleadApproveDeny.png
  inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_TeamleadOverviewOfTeam.png
  inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_TeamleadSeeHistory.png
  inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_UserApprovalForWeek.png
  inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_breaktimeRules.png
  inflating: ApprovalBundle-2.2.0/_documentation/Screenshot_settingsWorkdays.png
  inflating: ApprovalBundle-2.2.0/_documentation/troubleshoot_db_approval_status.png
  inflating: ApprovalBundle-2.2.0/composer.json
  inflating: ApprovalBundle-2.2.0/doc_troubleshooting.md
  inflating: ApprovalBundle-2.2.0/documentation.md
  inflating: ApprovalBundle-2.2.0/phpstan.neon
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# rm 2.2.0.zip
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins#

I can now reload

root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# cd ../..
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai# ls
CHANGELOG.md     Dockerfile  README.md    UPGRADING-1.md  bin            composer.lock  eslint.config.mjs  migrations   public  symfony.lock  translations  vendor
CONTRIBUTING.md  LICENSE     SECURITY.md  UPGRADING.md    composer.json  config         kimai.sh           php-cli.ini  src     templates     var           version.txt
root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai# bin/console kimai:reload

/content/images/2025/01/kimai-26.png

Install did not work, let me try moving to just a folder without version

root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai/var/plugins# mv ApprovalBundle-2.2.0 ApprovalBundle

That was it! That now worked to install

root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai# bin/console kimai:bundle:approval:install

Starting installation of plugin: ApprovalBundle ...
===================================================

[notice] Migrating up to ApprovalBundle\Migrations\Version20240828161654
[notice] finished in 704.2ms, used 20M memory, 9 migrations executed, 26 sql queries


 [OK] Successfully migrated to version: ApprovalBundle\Migrations\Version20240828161654





 [OK] Congratulations! Plugin was successful installed: ApprovalBundle


root@kimai-kimai2-587fb76f48-p67nz:/opt/kimai#

I can see a new timesheet approval section

/content/images/2025/01/kimai-27.png

Summary

Today we looked at two good options for Open-Source time trackers, Kimai and EigenFocus. EigenFocus is a good lightweight option best suited for local Docker. As it doesn’t have password auth or the idea of user accounts, it really wouldn’t work for a larger roll out. But I found the interface very clean and responsive.

Kimai is a bit more expansive in features. It has user accounts out of the box and a rich plugin repository one can use to expand it. I really had no issues installing it and I think the 3 to 4 Euro price for a hosted option is more than reasonable.

OpenSource Timetracking EigenFocus Kimai Docker Kubernetes

Have something to add? Feedback? You can use the feedback form

Isaac Johnson

Isaac Johnson

Cloud Solutions Architect

Isaac is a CSA and DevOps engineer who focuses on cloud migrations and devops processes. He also is a dad to three wonderful daughters (hence the references to Princess King sprinkled throughout the blog).

Theme built by C.S. Rhymes