Franklin Safari · Engineer, Gym-goer, engineer

I built Train3D because no app covered the whole of my training week.

I wanted one place that taught me the floor, tracked what I ate, and stayed out of the way. I wrote it as a gym-goer first, then as the person who had to keep it running. This page is that walkthrough: the problem, the design, then the path from a green test to a ready process.

How it started

Why it exists

I was tired of a fitness stack that never quite fit

Nothing I tried fully solved my own training. I wanted one app that held every part of the week: the floor, the kitchen, recovery, the bits I forget to open. Then I wanted it out of the way. Those gaps are the requirements. The rest of this page is how I designed the system and how I ship it.

The floor

Machines I didn’t know, sessions I actually own and that keep me accountable to my goals

I walk into a gym and there is always a machine I have not used. I wanted the app to show me how, in 3D, instead of guessing from a diagram on the stack.

I also wanted to build my own work: slowly, by hand, when the loading has to be exact; quickly, with the assistant, when I just need a draft. On days I have more in the tank I add another session rather than pretending the plan was enough. The split I set is the one the session holds me to.

Build Your Split: choose build muscle or build strength
Build Your Split: choose how many training days

The kitchen

How I eat toward the same goals I train for

Five years of lifting and I still was not gaining. The split told me what to do on the floor. Nothing did that for food. I needed a daily calorie and protein number, and a way to see whether I was actually getting there.

I set my age, how active I am, and a weight I am aiming for. The app calculates the day’s calories and protein, and I use those as the macro goals I eat against.

The food diary is the check. I scan a meal and it shows what I have already eaten against what is still left, so I can tell how close I am. Some days I export the daily food report just to keep consumed, goals, and remaining on one page.

When I am staring at whatever is in the fridge, I give AI Chef a photo or type the ingredients. It comes back with a high-protein, macro-friendly recipe.

Nutrition Tracker: Set your targets with age, activity, and a weight goal
Food diary: daily macros remaining, a scanned breakfast, and scan a meal

Around the clock

Yoga after lifting. A class when the kitchen needs a push.

Training was never just the last set. Wellness places live in the app so I can book a studio on the way out of the gym. Cooking classes showed up the same way, as a way to make the nutrition side less of a chore.

Wellness Centers also lists boxing and the rest near me: yoga, physio, pilates, a gym, with distances. I book nearby from the row, or any other class the pills turn up.

Wellness booking: yoga studio Book a Session sheet, cooking classes on the same screen
Wellness Centers: Boxing selected, top rated near you with Book, more recommendations with distances

Without opening the app

Siri for the day’s status. Health for the session I already did.

I got tired of unlocking the phone to ask whether I hit water, calories, or the next workout. Siri reads that from an on-device snapshot. Apple Health holds live heart rate and active calories while I train, and takes the completed session, meals, and water back into Fitness, so the tracking I already do in Train3D is the tracking the rest of the phone sees.

Voice assistants screen with Siri phrases for Train3D daily status
Connected Devices showing Apple Health connected with last sync time

App demo

The product, running

The surface I designed against. Autoplaying. Expand for the full frame.

Demo video Drop the file at video/app-demo.mp4

Systems

Design the boundary. Then prove the pipeline.

I start with one trust boundary and two store-bound binaries. Then CI tests the change, stamps a digest, and only a ready image becomes a process. I work in Cursor so I can write, run, and correct inside that loop.

  1. Requirements
  2. Design
  3. Clients
  4. CI/CD
  5. Release
  6. Operate
  7. Verify

01 · Design · Spring Boot 4 BFF

A single trust boundary between Expo and Appwrite

WorkoutAppApplication is the only process that sees server credentials. Spring Security authenticates Appwrite sessions, virtual threads take the I/O, and expensive routes like chat, vision, and webhooks sit on explicit refill budgets. RFC 7807 errors. Actuator liveness and readiness. Java 25 on the production image.

Watch it come live
  • Java
  • Spring Security
  • REST
  • Webhooks
  • Rate limits
Terminal capture of ./mvnw spring-boot:run starting Train3D

02 · Clients · Expo · TypeScript

One codebase, two store-bound binaries

The phones stay dumb to secrets. I ship iOS and Android from Expo 54: Router, HealthKit / Health Connect, camera capture for equipment and meals, OAuth plus Sign in with Apple and Google, biometrics. EAS production profiles follow the git SHA, so a store binary traces back to the same commit the API does.

  • TypeScript
  • React Native
  • EAS
  • OAuth
  • Sign in with Apple
  • Sign in with Google
Appwrite Train3D project overview: platforms, bandwidth, and requests
Expo EAS production builds for Train3D iOS and Android

03 · CI/CD · GitHub Actions · Docker Hub

Test. Package. Tag the SHA. Promote only from HEAD.

Nothing ships by hand. On main, CI Backend and CI Frontend run first. Then Maven packages, Buildx builds linux/amd64, and the pipeline pushes franklinamani/train-3d-api:<sha>. It retags :latest only if that commit is still HEAD. Railway and the cluster pull that image. The registry currently shows 1.4K pulls on the API. That is the software development lifecycle I actually run: test, build, release, then prove the process is ready.

  • CI/CD
  • Docker
  • Railway
  • Kubernetes YAML
GitHub Actions: CI Backend and CI Frontend on workoutApp main
Docker Hub repository franklinamani/train-3d-api: latest and SHA tags

Gate · Boot capture

Published image → Tomcat → readiness UP

A green pipeline is not enough. I ran docker run --rm -p 8080:8080 franklinamani/train-3d-api:latest with no secret file. Java 25.0.3, Spring Boot 4.0.6, Tomcat 11 on 8080. Then GET /actuator/health/readiness returned {"status":"UP"}. The clip is that full sequence, not a truncated banner. If readiness is not UP, the image does not count as released.

04 · Release · Docker · Kubernetes

The image becomes a process. Secrets stay out of git.

Release is a contract, not a laptop ritual. The Dockerfile and the k8s manifests are public. Credential values are not. Production injects them from a cluster secret created off a local .env that is never committed. The SHA from CI is the tag the cluster runs.

01

Multi-stage image, digest-pinned Temurin 25

Build stage caches dependency:go-offline, packages with tests skipped (CI already ran them), and copies one jar. Runtime is JRE-only, non-root UID/GID 1001, container-aware heap at 60%, and a HEALTHCHECK on readiness. I do not default SPRING_PROFILES_ACTIVE=prod in the image. Compose stays local. Railway and Kubernetes set prod at runtime.

# BACKEND/Dockerfile. No keys, no .env, no tokens
FROM eclipse-temurin:25-jdk-noble@sha256:… AS build
WORKDIR /workspace
COPY mvnw .mvn pom.xml ./
RUN ./mvnw -B -q dependency:go-offline
COPY src src
RUN ./mvnw -B -q -Dmaven.test.skip=true package \
  && mv target/workoutApp-*.jar target/app.jar

FROM eclipse-temurin:25-jre-noble@sha256:… AS runtime
RUN useradd --system --uid 1001 --gid spring spring
USER spring:spring
ENV PORT=8080
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=60.0 -XX:+ExitOnOutOfMemoryError"
HEALTHCHECK CMD curl -f http://localhost:${PORT}/actuator/health/readiness
ENTRYPOINT ["java", "-jar", "app.jar"]
02

Secret object, never a secret in YAML

The Deployment lists only non-secret env: port, prod profile, membership test flags off, principal cache off, forwarded-header trust on. Appwrite, OpenAI, RevenueCat, and CORS origins arrive through envFrom.secretRef. The create command is the contract; the values stay on the operator machine.

# values never printed, never committed
kubectl create secret generic train-3d-api-secrets \
  --from-env-file=BACKEND/.env

# BACKEND/k8s/train-3d-api.yaml. Public knobs only
env:
  - name: SPRING_PROFILES_ACTIVE
    value: prod
  - name: APP_MEMBERSHIP_TEST_MODE
    value: "false"
envFrom:
  - secretRef:
      name: train-3d-api-secrets
03

Two replicas, SHA tag, probes, HPA

Image is franklinamani/train-3d-api:<GIT_SHA>, not :latest. Drop all capabilities, no privilege escalation, 250m/512Mi requests, 1 CPU / 1Gi limits. Readiness waits 40s on /actuator/health/readiness; liveness starts at 60s. Service 80→8080. HPA 2 to 10 at 70% CPU.

kubectl apply -f BACKEND/k8s/train-3d-api.yaml
kubectl set image deployment/train-3d-api \
  api=franklinamani/train-3d-api:$GIT_SHA

# spec excerpt
replicas: 2
securityContext: { runAsNonRoot: true, runAsUser: 1001 }
readinessProbe:
  httpGet: { path: /actuator/health/readiness, port: 8080 }
  initialDelaySeconds: 40
livenessProbe:
  httpGet: { path: /actuator/health/liveness, port: 8080 }
  initialDelaySeconds: 60
# HPA: min 2 / max 10 @ 70% CPU

05 · Operate · Mechanics

Four principles I kept in the running system

After release, the design still has to hold: skip hops I already paid for, keep fan-out off the phone, let the OS own samples, and lock the races I refuse to leave to the client. No TanStack Query, no Spring @Cacheable, no Redis, no ReentrantLock. These are the mechanisms that actually run.

Caching

Skip a hop I already paid for, then turn the risky one off in prod

AuthPrincipalCache is a 20s ConcurrentHashMap of validated principals so the filter does not call Appwrite GET /account on every BFF request. Logout invalidates that replica immediately. In BACKEND/k8s/train-3d-api.yaml I set APP_AUTH_PRINCIPAL_CACHE_ENABLED=false, because two replicas must not disagree about a revoked session for 20 seconds.

The caches that stay on are cheaper than their upstreams: YouTube L1 (24h memory) + L2 (90d Appwrite), USDA and barcode tables written off the critical path, Places once-per-user until a 300 km move, membership rows for 20s. Private APIs get Cache-Control: no-store via PrivateApiCacheHeadersWriter. The phone uses in-memory + AsyncStorage, not React Query.

AuthPrincipalCache.java, 20 second ConcurrentHashMap of validated principals
YouTubeVideoCacheService.java L1 memory and L2 Appwrite cache

RTT

One client call. The fan-out stays in the JVM.

Phones talk to the BFF, not to Appwrite tables. I collapse work that would otherwise be two or N mobile hops: RecipeService.generateFromPhoto runs identify then generate in one request; OwnWorkoutService.createBatch writes every draft and records streak once; ChatService.sendMessage persists, calls the model, persists, audits.

Inside a request, VirtualIo.EXECUTOR is a virtual-thread pool. FoodService.scan overlaps USDA lookups; CustomWorkoutService.generate overlaps draft, persist, and plan activate. Appwrite traffic reuses an HTTP/2 HttpClient. HealthKit and Health Connect writes stay on-device. That is a hop I never take.

RecipeService.generateFromPhoto keeping two model calls server-side
FoodService.scan overlapping USDA lookups on VirtualIo executor

Native OS

The OS owns samples and the Secure Enclave. I own aggregates and hashes.

createHealthKitAdapter reads heart rate, active energy, and workouts; it writes completed workouts, dietary energy, and water. Health Connect is the same set via insertRecords. There is no HKWorkoutSession and no background HealthKit delivery. Live metrics are a 3s foreground poll. The BFF receives daily burned kcal and session totals, not HR series.

Siri is five App Intents (Train3DDailySummaryIntent and siblings). Spoken strings come from an App Group snapshot the BFF assembled at GET /api/assistant/today-summary. Android is shortcuts.xml + OPEN_APP_FEATURE deep links. There is no spoken Google Assistant fulfillment, and no speech recognition. Face ID stays in the Secure Enclave; the BFF stores a SHA-256 of the device refresh token.

healthKitAdapter.ts HealthKit read and share quantity types
Train3DDailySummaryIntent Swift App Intent reading on-device snapshot

Locks

Per-key monitors, striped user locks, and event identity

TokenBucketRateLimiter.Bucket.tryConsume is synchronized on the bucket, not the process. Contention is per caller. AI quotas go through MembershipUsageService.lockFor(userId), a 64-slot striped lock, so two advisor calls cannot both pass the monthly cap. GooglePlacesCacheService double-checks under a per-user lock so concurrent place fetches share one Google request.

Races I refuse to leave to the client: MembershipService.applyBillingTier no-ops duplicate or stale RevenueCat eventId / eventTimestampMs. WorkoutSessionService stores exerciseCooldowns (300 minutes in k8s) so recovery cannot be skipped. Custom-workout generate is gated by inFlightRef on the phone. No ReentrantLock, no production Atomic*. The monitors above are the ones that fire.

MembershipUsageService.consumeAdvisorQuota synchronized per-user lock
MembershipService.applyBillingTier ignoring duplicate and stale RevenueCat events

06 · Verify · Evidence

Consoles I operate, not slides I invented

The system in the consoles that run it, from EAS production artifact builds for both native platforms, to RevenueCat for payment processing, to the Docker image itself from a safe and reliable CI/CD path.

Current Expo EAS production builds for Train3D iOS and Android

EAS production, current Builds

Live Expo Builds screen: store-bound iOS and Android artifacts on the train3d project, through 1.0.0 (19).

RevenueCat webhook train-3d-api-production with sent events

RevenueCat → Railway

/api/webhooks/revenuecat on the production host. Authorization header stays masked. Production and sandbox. Rows marked Sent.

Docker Hub showing train-3d-api image pulls

Registry

The API image hiring managers can pull. SHA tags in CI, :latest only from HEAD.

Expanded RevenueCat webhook request JSON for an Elite_monthly cancellation, subscriber ids redacted

Webhook payload the BFF already answered 200

Expanded event JSON: CANCELLATION / Elite_monthly / APP_STORE / SANDBOX, with $RCAnonymousID and app_user_id redacted before I published the shot. Response tab is 200.

RevenueCat App Store products Plus Monthly, Elite Yearly, and Elite Monthly

iOS tiers

App Store: Plus_monthly, Elite_monthly, Elite_yearly. One entitlement each.

RevenueCat Play Store products plus_monthly, elite_monthly, and elite_yearly published

Android tiers

Play Store, published: plus_monthly:plus-monthly, elite_monthly:elite-monthly, elite_yearly:elite-yearly.

Pleased to connect