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.
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.
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.
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.
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.
App demo
The product, running
The surface I designed against. Autoplaying. Expand for the full frame.
Demo videoDrop 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.
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.
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
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
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.
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.
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.
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.
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.
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.
EAS production, current Builds
Live Expo Builds screen: store-bound iOS and Android artifacts on the train3d project, through 1.0.0 (19).
RevenueCat → Railway
/api/webhooks/revenuecat on the production host. Authorization header stays masked.
Production and sandbox. Rows marked Sent.
Registry
The API image hiring managers can pull. SHA tags in CI, :latest only from HEAD.
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.
iOS tiers
App Store: Plus_monthly, Elite_monthly,
Elite_yearly. One entitlement each.
Android tiers
Play Store, published: plus_monthly:plus-monthly,
elite_monthly:elite-monthly,
elite_yearly:elite-yearly.