Track five things a day for seventy-five days

The workout, the protein, the calories, the water, and three lines of
gratitude. One page per day, a card of seventy-five tiles that fill by
how many of the five landed, and a missed day left as a gap rather than
a reset.

Single Go binary with no dependencies. The log is one JSON file on a
mounted volume, written atomically and never silently replaced when it
fails to parse, since it is the one thing here that cannot be recreated.
Auth is Traefik basic auth at the ingress; the app has no login of its
own, so every POST checks Sec-Fetch-Site to stop another origin posting
with those credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 21:59:01 -07:00
commit faf73c8be5
25 changed files with 3197 additions and 0 deletions

156
k8s.yaml Normal file
View File

@@ -0,0 +1,156 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: tracker
---
# The whole log is one JSON file, so the "database" is this volume. It is
# ReadWriteOnce, which is why the deployment below replaces rather than rolls.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: tracker-data-pvc
namespace: tracker
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: tracker-svc
namespace: tracker
spec:
selector:
app: tracker
ports:
- port: 80
targetPort: 8080
protocol: TCP
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: tracker-dep
namespace: tracker
spec:
replicas: 1
# Two pods would both hold the file in memory and overwrite each other's
# writes, and the volume only attaches to one node anyway. Recreate makes the
# old pod let go before the new one starts.
strategy:
type: Recreate
selector:
matchLabels:
app: tracker
template:
metadata:
labels:
app: tracker
spec:
imagePullSecrets:
- name: harborcred
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
# Without fsGroup the volume comes up owned by root and nobody cannot
# write the file it exists to hold.
fsGroup: 65534
containers:
- name: tracker
image: harbor.scottyah.com/scottyah/tracker:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
env:
- name: ADDR
value: ":8080"
- name: BASE_URL
value: "https://fit.scottyah.com"
- name: DATA_PATH
value: "/data/tracker.json"
# Every date in the app is a local date, so this decides when the
# day rolls over.
- name: TZ_NAME
value: "America/Los_Angeles"
- name: GOMEMLIMIT
value: "40MiB"
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: "16Mi"
cpu: "10m"
limits:
memory: "64Mi"
cpu: "200m"
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 20
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 2
periodSeconds: 10
securityContext:
allowPrivilegeEscalation: false
# /data is the only thing the process writes.
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: data
persistentVolumeClaim:
claimName: tracker-data-pvc
---
# The app has no login of its own; this is the only thing between the log and
# the internet, so the Service must not be exposed any other way.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: tracker-basic-auth
namespace: tracker
spec:
basicAuth:
secret: tracker-basic-auth
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tracker-ingress
namespace: tracker
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.middlewares: tracker-tracker-basic-auth@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- fit.scottyah.com
secretName: scottyah-tls
rules:
- host: fit.scottyah.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: tracker-svc
port:
number: 80