openGym on Kubernetes
Chris Cowley
- 8 minutes read - 1637 wordsFor a while I have been looking for a good self-hosted solution for recording my gym sessions. I have a good solution for tracking my cardio sessions (cycling and running mainly) in Endurain, but never for strength training. I have tried a few:
- wger - a good solution, but the UI is a bit dated and it is also geared towards managing a gym rather than a single user.
- Wingfit - also good, but I just did not click with it. Shame, because the developer is from Brittany, which is my adopted home.
- A multitude of different open source Android apps. These all had UX issues and no syncing, so kind of sucked for me.
As a result, I stayed on Strong for much longer than I would have liked.
A couple of days ago I noticed that someone was running openGym and it caught my attention. A quick look told me it:
- could import my Strong data
- would allow me to make as many programs as I like
- had an AI coaching assistant (not essential, but nice to have)
- had an Android app and a backend for self-hosting
The project had an example Docker Compose file and I used that as a starting point to deploy on my k3s cluster.
The manifests
openGym is pretty simple - a frontend and an API backend. The API keeps everything in a plain JSON file on a volume. The Docker Compose file also has a third container that syncs a bunch of media files. I only have a few resources to create:
- 2 PVCs: data and media
- 1 deployment for the API and web interface: I decided to keep them in a single pod for simplicity.
- An HTTPRoute (I use Envoy Gateway nowadays) to expose it to the outside world.
The files are pretty simple:
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: opengym-data
namespace: fitness
spec:
storageClassName: longhorn
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: opengym-media
namespace: fitness
spec:
storageClassName: longhorn
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
Our deployment is not surprising:
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: opengym
name: opengym
namespace: fitness
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: opengym
template:
metadata:
labels:
app: opengym
spec:
initContainers:
- name: media-download
image: alpine/git
command: ["/bin/sh", "-c"]
args:
- |
mkdir -p /out/img /out/gif
if [ -z "$(ls -A /out/img 2>/dev/null)" ]; then
git clone --depth 1 https://github.com/hasaneyldrm/exercises-dataset /tmp/ds
cp /tmp/ds/images/*.jpg /out/img/ && cp /tmp/ds/videos/*.gif /out/gif/
fi
volumeMounts:
- name: opengym-media
mountPath: /out
resources:
requests:
memory: 128Mi
limits:
memory: 256Mi
containers:
- name: api
image: ghcr.io/duartesantos8/opengym-api:latest
imagePullPolicy: Always
env:
- name: PORT
value: "3000"
- name: DATA_DIR
value: /data
- name: RP_ID
value: opengym.lab.cowley.tech
- name: ORIGIN
value: https://opengym.lab.cowley.tech
- name: COACH_JOB_TIMEOUT_MS
value: "600000"
- name: ADMIN_UIDS
value: "wy6dtg99dQxliyyh"
- name: INVITE_ONLY
value: "true"
- name: ALLOW_GUEST
value: "false"
ports:
- containerPort: 3000
name: http
protocol: TCP
readinessProbe:
httpGet:
path: /api/health
port: 3000
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 30
volumeMounts:
- name: opengym-data
mountPath: /data
resources:
requests:
memory: 128Mi
limits:
memory: 512Mi
- name: web
image: ghcr.io/duartesantos8/opengym-web:latest
imagePullPolicy: Always
env:
- name: NGINX_PORT
value: "80"
- name: BACKEND
value: 127.0.0.1
- name: PORT
value: "3000"
ports:
- containerPort: 80
name: web
protocol: TCP
readinessProbe:
httpGet:
path: /
port: 80
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 30
volumeMounts:
- name: opengym-media
mountPath: /usr/share/nginx/html/img
subPath: img
- name: opengym-media
mountPath: /usr/share/nginx/html/gif
subPath: gif
resources:
requests:
memory: 32Mi
limits:
memory: 128Mi
volumes:
- name: opengym-data
persistentVolumeClaim:
claimName: opengym-data
- name: opengym-media
persistentVolumeClaim:
claimName: opengym-media
There are a few things to notice here. I put the media job into an initContainer. I see no reason to have this running alongside the API and web containers. The initContainer mounts a single PersistentVolume, which is then split into img and gif in the web container using a subPath for each.
The web container is configured with 127.0.0.1 as the backend.
Info
This is an opportunity for a quick reminder about Pod networking. All containers in a Pod share the same network namespace, so they can communicate with each other using localhost or 127.0.0.1. This is a very useful feature of Kubernetes Pods, and it is often used to run multiple containers that need to communicate with each other.
There are 3 env variables I did not set initially:
- ADMIN_UIDS
- INVITE_ONLY
- ALLOW_GUEST
Initially I left these out and did a first login, which created my initial account. Once that was done, I got the UID of my user from /data/db.json in the api container. If you want to be a little flashy you can run:
kubectl -n fitness exec deployments/opengym -c api \
-- cat /data/db.json | jq -r .users[0].id
That will give you the UID directly. I do not consider that to be a secret, so I just put it straight in Git. You could always put it in a secret if you prefer and reference it using envFrom.
Finally, we need our Service and HTTPRoute:
---
apiVersion: v1
kind: Service
metadata:
name: opengym
namespace: fitness
spec:
type: ClusterIP
ports:
- name: web
port: 80
protocol: TCP
targetPort: web
selector:
app: opengym
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: opengym
namespace: fitness
spec:
parentRefs:
- name: eg
namespace: envoy-gateway-system
sectionName: https
hostnames:
- opengym.lab.cowley.tech
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: opengym
port: 80
Nothing surprising here. The HTTPRoute and Service simply point to port 80 (nginx) on the web container. The web container then proxies requests to the api container on port 3000.
Importing history from Strong
The openGym documentation implies this should just work, but I had a couple of issues. Strong exports a CSV file, but it does not have the correct headers, nor the delimiter for openGym to import.
The solution I found is simple, requiring nothing more than 2 minutes in vi. Load the CSV file and rename the headers so they match what openGym is looking for (documented here). You then need to replace the delimiters, as Strong uses ; rather than the , that openGym expects. You can do it in vi, but I used a quick sed -i 's/;/,/g strong-export.csv. I put that file on my phone and it imported straight away.
The AI coach
In the intro I wrote the AI coaching assistant off as “not essential, but nice to have”. I am happy to report I was wrong about the nice-to-have part - it is genuinely useful, and the fact that it runs completely on my own hardware makes it even better.
The coach is configured purely from the UI: Settings → Admin → AI Coach. No new env vars in the manifests, nothing to restart. You pick a provider, point it at your model, and you are done.
Since Ollama is already running on my cluster, in its own ollama namespace, I chose the OpenAI-compatible endpoint option and gave it the in-cluster address of my Ollama service: http://ollama.ollama:11434 (that is service.namespace - the ollama service in the ollama namespace). No API key - it is on my network, it does not need one. You enter the base URL only; openGym appends /v1/chat/completions itself, which is exactly the endpoint Ollama exposes for OpenAI-compatible clients. The UI even ships with http://ollama:11434 as its placeholder, so it is safe to say the project knows who its audience is.
The model choice turned out to matter more than I expected. My first attempt was gemma4:e4b, but the smaller model was not very effective - the plans it came up with just felt off. Switching to gemma4:12b made a real difference, it really does seem to need the larger model. The good news is the coach is text-only: you chat with it, answer some questions, it reads your logged sets, reps and weights rather than analysing photos or videos, so you do not need a vision-capable model (or a GPU) to get real value out of it.
The one coach-related setting you may want to tune is COACH_JOB_TIMEOUT_MS, which budgets a single coach call. The default is 5 minutes; mine is set to 600000 (10 minutes) in the deployment above, since a 12B model on a homelab box can take a while to answer. If you try this yourself, it is also worth keeping the model warm on the Ollama side with OLLAMA_KEEP_ALIVE=-1 and OLLAMA_NUM_PARALLEL=1 - otherwise the first review after a reboot spends most of its budget just loading.
Conclusion
Looking back, the Kubernetes side of this was the easy part. A couple of PVCs, one deployment, an HTTPRoute - I had it running in an evening, and none of it was surprising. For a simple app like this, the infrastructure is almost boring nowadays.
The real friction was the data, as it usually is. The only time I actually had to stop and think was getting years of Strong history through a CSV that refused to play nice. Swap the headers, swap the delimiters, job done - but budget an hour for it if you are coming from Strong.
So far openGym is clicking with me in a way wger and Wingfit never did. Unlimited programs, my own data on my own hardware, and an import that means I did not lose years of history. Even the AI coach, which I had written off as a gimmick, is pulling real weight from a model running on my own cluster. And yes, the fact that the project comes from Brittany, my adopted home, does not hurt its chances either.
My advice if you want to do the same: do not sweat the deployment, follow the compose file, and give the CSV import your attention instead. Strong served me well for a long time, but I am happier knowing my gym data lives on my own cluster - where it belongs.