# Tavolo — Kubernetes deployment (namespace: tavolo) # # Single apply: # kubectl apply -f deploy/k8s/tavolo.yaml # # Replace every value marked REPLACE_ME before applying. # # Scope: # - Postgres and the OIDC provider are NOT part of this file; they are # deployed in other namespaces and referenced by DNS name. # - Redis IS included (ephemeral: sessions and live games are disposable, # mirroring docker-compose's no-volume choice). # # Reachability notes: # - The OIDC issuer URL must resolve from the browser AND from the pods # (kaya does discovery lazily on the first login, so startup succeeds # even when the issuer is unreachable, but logins then fail). If the # provider is internal-only, you need split DNS or a public issuer URL. # - The Postgres NetworkPolicy (if any) must allow ingress from the # tavolo namespace. # - This file deploys a ClusterIP Service only. Until you add an Ingress, # reach the app with: # kubectl port-forward -n tavolo svc/tavolo 8080:80 # and set OIDC_REDIRECT_URI to match whatever URL the browser uses. --- apiVersion: v1 kind: Namespace metadata: name: tavolo labels: app.kubernetes.io/name: tavolo app.kubernetes.io/part-of: tavolo --- apiVersion: v1 kind: ConfigMap metadata: name: tavolo-config namespace: tavolo labels: app.kubernetes.io/name: tavolo app.kubernetes.io/component: app app.kubernetes.io/part-of: tavolo data: # In-cluster Redis deployed by this file. REDIS_URL: redis://redis.tavolo.svc.cluster.local:6379/0 # Postgres (external, lives in another namespace): everything except the # password, which is the only entry in the tavolo-secrets Secret. # Use its Service DNS name, e.g. postgres..svc.cluster.local. DATABASE_ENGINE: postgres DATABASE_HOST: REPLACE_ME DATABASE_PORT: "5432" DATABASE_NAME: REPLACE_ME DATABASE_USER: REPLACE_ME # Extra DSN query parameters appended to the URL (e.g. ssl=require). # Empty means none. DATABASE_OPTIONS: "" # The image bakes STATIC_DIR=/app/web/dist; repeat it here for clarity. STATIC_DIR: /app/web/dist GAME_TTL_SECONDS: "86400" HAND_ACK_TIMEOUT_SECONDS: "30" TURN_TIMEOUT_SECONDS: "30" # CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or # CORS_ALLOW_ORIGIN_REGEX is set — unneeded when the SPA and the API are # served from the same origin. See server/.env.example for details. # CORS_ALLOW_ORIGINS: "https://example.com,https://app.example.com" # or "*" # CORS_ALLOW_ORIGIN_REGEX: 'https://tavolo-[a-z0-9-]+\.vercel\.app' # CORS_ALLOW_METHODS: "GET,POST" # default: GET; "*" = all # CORS_ALLOW_HEADERS: "Authorization,Content-Type" # "*" mirrors the request # CORS_ALLOW_CREDENTIALS: "false" # CORS_EXPOSE_HEADERS: "" # CORS_MAX_AGE: "600" # OIDC (provider lives in another namespace). OIDC_CLIENT_ID: tavolo OIDC_POST_LOGIN_REDIRECT: / OIDC_POST_LOGOUT_REDIRECT: / # Issuer URL as seen by the browser AND the pods (see notes above). OIDC_ISSUER: REPLACE_ME # Public callback URL of this deployment, e.g. # http://:/auth/callback (must be allowed at the provider). OIDC_REDIRECT_URI: REPLACE_ME --- apiVersion: v1 kind: Secret metadata: name: tavolo-secrets namespace: tavolo labels: app.kubernetes.io/name: tavolo app.kubernetes.io/component: app app.kubernetes.io/part-of: tavolo type: Opaque stringData: # The only Postgres secret: the password for DATABASE_USER at # DATABASE_HOST (both configured in the tavolo-config ConfigMap). DATABASE_PASSWORD: REPLACE_ME # Client secret for OIDC_CLIENT_ID at the provider. OIDC_CLIENT_SECRET: REPLACE_ME --- # Redis: sessions + live game state. Ephemeral by design (emptyDir): worst # case after a restart users log in again and games in progress expire. apiVersion: apps/v1 kind: Deployment metadata: name: redis namespace: tavolo labels: app.kubernetes.io/name: tavolo app.kubernetes.io/component: redis app.kubernetes.io/part-of: tavolo spec: replicas: 1 # Redis is a singleton here; avoid a window with two masters on rollout. strategy: type: Recreate selector: matchLabels: app.kubernetes.io/name: tavolo app.kubernetes.io/component: redis template: metadata: labels: app.kubernetes.io/name: tavolo app.kubernetes.io/component: redis app.kubernetes.io/part-of: tavolo spec: securityContext: runAsNonRoot: true runAsUser: 999 # redis user in the official image fsGroup: 999 containers: - name: redis image: redis:8-alpine # Snapshots would only live on an emptyDir anyway: skip them. args: ["--save", "", "--appendonly", "no"] ports: - name: redis containerPort: 6379 volumeMounts: - name: data mountPath: /data readinessProbe: exec: command: ["redis-cli", "ping"] periodSeconds: 5 livenessProbe: exec: command: ["redis-cli", "ping"] periodSeconds: 10 resources: requests: cpu: 25m memory: 64Mi limits: cpu: 250m memory: 128Mi volumes: - name: data emptyDir: {} --- apiVersion: v1 kind: Service metadata: name: redis namespace: tavolo labels: app.kubernetes.io/name: tavolo app.kubernetes.io/component: redis app.kubernetes.io/part-of: tavolo spec: type: ClusterIP selector: app.kubernetes.io/name: tavolo app.kubernetes.io/component: redis ports: - name: redis port: 6379 targetPort: redis --- # The app (backend + compiled SPA + WebSocket endpoint, one image). # The "migrate" initContainer runs `aerich upgrade` before the app starts, # so this single file can be applied safely in any order. Migrations are # idempotent, but if you ever scale above 1 replica, move this to a Job to # avoid concurrent upgrades. apiVersion: apps/v1 kind: Deployment metadata: name: tavolo namespace: tavolo labels: app.kubernetes.io/name: tavolo app.kubernetes.io/component: app app.kubernetes.io/part-of: tavolo spec: replicas: 1 selector: matchLabels: app.kubernetes.io/name: tavolo app.kubernetes.io/component: app template: metadata: labels: app.kubernetes.io/name: tavolo app.kubernetes.io/component: app app.kubernetes.io/part-of: tavolo spec: # Let WebSocket connections drain on rollout. terminationGracePeriodSeconds: 30 securityContext: runAsNonRoot: true initContainers: - name: migrate image: gitea.woggioni.net/woggioni/tavolo:latest # aerich reads [tool.aerich] from pyproject.toml in /app. command: ["aerich", "upgrade"] workingDir: /app envFrom: # Migrations need the non-secret DATABASE_* parts too. - configMapRef: name: tavolo-config - secretRef: name: tavolo-secrets resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 256Mi containers: - name: tavolo # Pin a release/* tag for production instead of :latest. image: gitea.woggioni.net/woggioni/tavolo:latest ports: - name: http containerPort: 8080 envFrom: - configMapRef: name: tavolo-config - secretRef: name: tavolo-secrets securityContext: allowPrivilegeEscalation: false capabilities: drop: ["ALL"] readOnlyRootFilesystem: true volumeMounts: - name: tmp mountPath: /tmp startupProbe: httpGet: path: /api/health port: http periodSeconds: 2 failureThreshold: 30 readinessProbe: httpGet: path: /api/health port: http periodSeconds: 10 livenessProbe: httpGet: path: /api/health port: http periodSeconds: 10 resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi volumes: - name: tmp emptyDir: {} --- apiVersion: v1 kind: Service metadata: name: tavolo namespace: tavolo labels: app.kubernetes.io/name: tavolo app.kubernetes.io/component: app app.kubernetes.io/part-of: tavolo spec: type: ClusterIP # No session affinity needed: sessions and live games live in Redis. selector: app.kubernetes.io/name: tavolo app.kubernetes.io/component: app ports: - name: http port: 80 targetPort: http