diff --git a/Makefile b/Makefile
index ed2a18f..314f123 100644
--- a/Makefile
+++ b/Makefile
@@ -6,8 +6,9 @@ VERSION = 0.1.1
help:
@echo "📋 Available targets:\n"
@echo "🏗️ Build & Setup:"
- @echo " make build Build development image, boot stack, and open browser"
- @echo " make image Build development Docker image"
+ @echo " make start Build development image, boot stack, initialize db, and open browser"
+ @echo " make dev Build development Docker image"
+ @echo " make prod Build production Docker images (app + web)"
@echo " make up Boot the Docker stack"
@echo " make down Shut down the Docker stack"
@echo " make init Initialize app (composer, database, fixtures)"
@@ -30,12 +31,17 @@ help:
@echo " make clear Clear all caches"
@echo " make open Open application in browser\n"
-build: image up open
+start: build up init open
+
+build:
+ @echo "🏗️ Building development image(s)..."
+ docker build . -f ./build/php/Dockerfile --target dev --no-cache -t ${APP_NAME}-dev:${VERSION}
+
+prod:
+ @echo "🛳️ Building procution image(s)..."
+ docker build . -f ./build/php/Dockerfile --target prod --no-cache -t ${APP_NAME}:${VERSION}
+ docker build . -f ./build/nginx/Dockerfile --no-cache -t ${APP_NAME}-web:${VERSION}
-image:
- @echo "🏗️ Building development image..."
- docker build . -f ./build/php/Dockerfile --target dev -t ${APP_NAME}-dev:${VERSION}
-
up:
@echo "🚀 Booting Docker stack..."
docker compose up -d --remove-orphans
diff --git a/README.md b/README.md
index a6bbb47..5672968 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,108 @@ Track shared costs, calculate who owes whom, and manage group finances effortles
Follows DDD principles for separting the application into generic, core, and supporting domains - enforced by [deptrac](https://github.com/deptrac/deptrac).
+## Kubernetes Deployment
+
+This application is fully containerized and deployable to Kubernetes. Use the Helm chart to deploy:
+
+```bash
+# Deploy to Kubernetes (requires kind, minikube, or Docker Desktop with K8s enabled)
+helm install app ./helm
+
+# Access the application
+kubectl port-forward svc/app-split-fairly-web 8080:80
+# → http://localhost:8080
+
+# Or via NodePort (if enabled)
+# → http://localhost:30190
+```
+
+### Kubernetes Architecture
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Kubernetes Cluster │
+├─────────────────────────────────────────────────────────────┤
+│ │
+│ ┌──────────────────┐ ┌──────────────────┐ │
+│ │ nginx (web) │ │ app (PHP-FPM) │ │
+│ │ Split-Fairly │ │ Deployment │ │
+│ │ NodePort:30190 │─→│ Pods × 1 │ │
+│ └──────────────────┘ └──────────────────┘ │
+│ │ │ │
+│ │ Serves SPA │ Processes requests │
+│ │ EasyAdmin │ Event sourcing │
+│ │ │ Session management │
+│ │ ┌─────▼──────┐ │
+│ │ │ worker │ │
+│ │ │ Pod × 1 │ │
+│ │ │ Async jobs │ │
+│ │ └────────────┘ │
+│ │ │ │
+│ └───────────┬───────────┘ │
+│ │ │
+│ ┌──────▼──────┐ │
+│ │ MySQL │ │
+│ │ StatefulSet │ │
+│ │ PVC Storage │ │
+│ └─────────────┘ │
+│ △ │
+│ │ init Job │
+│ ┌──────┴──────┐ │
+│ │ db-init │ │
+│ │ (one-time) │ │
+│ └─────────────┘ │
+│ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+**Components:**
+- **nginx (web)**: Serves React SPA frontend, EasyAdmin assets, proxies API to PHP
+- **PHP-FPM (app)**: Symfony backend, handles business logic & API endpoints
+- **Worker**: Processes async jobs via Messenger (background tasks)
+- **MySQL**: Persistent data storage with PVC
+- **db-init Job**: One-time database initialization (schema + fixtures)
+
+**Access:** `http://localhost:30190` (direct) or `http://localhost:8080` (port-forward)
+
+### Getting Started with Kubernetes
+
+```bash
+# Prerequisites: Docker Desktop (or kind/minikube) with Kubernetes enabled
+
+# Build production images
+make prod
+
+# Deploy to cluster
+helm upgrade --install app ./helm
+
+# Watch pods come up
+kubectl get pods -w
+
+# View application logs
+kubectl logs deployment/app-split-fairly-app -f
+kubectl logs deployment/app-split-fairly-worker -f
+
+# Access the application
+# - Direct: http://localhost:30190
+# - Port-forward: kubectl port-forward svc/app-split-fairly-web 8080:80
+
+# Login credentials (auto-loaded from fixtures)
+# Email: admin@example.com
+# Password: secret
+```
+
+## Local Development (Docker Compose)
+
+For development without Kubernetes:
+
+```bash
+make start # Build images, start services, and open in browser
+make help # Show all available targets
+```
+
+Visit `http://localhost:8000` in your browser.
+
## Architecture diagram
```
@@ -53,19 +155,38 @@ Follows DDD principles for separting the application into generic, core, and sup
## Prerequisites
+### For Docker Compose (Local Development)
- [Make](https://www.gnu.org/software/make/)
- [Docker](https://www.docker.com/)
- [Docker Compose](https://docs.docker.com/compose/)
+### For Kubernetes Deployment
+- [Make](https://www.gnu.org/software/make/)
+- [Docker](https://www.docker.com/)
+- [Kubernetes](https://kubernetes.io/) (kind, minikube, or Docker Desktop)
+- [Helm 3](https://helm.sh/)
+
## Getting Started
+### Quick Start (Docker Compose)
+
```bash
-make build # Build image, start services, and open in browser
-make init # Initialize database and load fixtures
+make start # Build image, start services, and open in browser
+make help # to show all targets
```
Visit `http://localhost:8000` in your browser.
+### Kubernetes Deployment
+
+```bash
+make prod # Build production images
+helm install app ./helm # Deploy to Kubernetes
+kubectl port-forward svc/app-split-fairly-web 8080:80 # Access app
+```
+
+Visit `http://localhost:8080` (or `http://localhost:30190` directly if NodePort is enabled).
+
## Screenshots
diff --git a/backend/.env b/backend/.env
index d5985f1..0d461e2 100644
--- a/backend/.env
+++ b/backend/.env
@@ -15,7 +15,7 @@
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
-APP_ENV=dev
+APP_ENV=prod
APP_SECRET=
APP_SHARE_DIR=var/share
APP_VERSION=0.0.1
diff --git a/backend/composer.json b/backend/composer.json
index c83d2ab..7d1c393 100644
--- a/backend/composer.json
+++ b/backend/composer.json
@@ -8,6 +8,7 @@
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/doctrine-bundle": "^3.2.2",
+ "doctrine/doctrine-fixtures-bundle": "^4.3",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6.2",
"dompdf/dompdf": "^3.1",
@@ -104,7 +105,6 @@
},
"require-dev": {
"deptrac/deptrac": "^4.5",
- "doctrine/doctrine-fixtures-bundle": "^4.3.1",
"friendsofphp/php-cs-fixer": "^3.93.1",
"phpstan/phpstan": "^2.1.38",
"phpunit/phpunit": "^12.5.8",
diff --git a/backend/composer.lock b/backend/composer.lock
index b18745c..10bd5e9 100644
--- a/backend/composer.lock
+++ b/backend/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "e3183f3ee6ded4f46d8380294176679b",
+ "content-hash": "d68dee5a5cf2d58e0d80cb04c6e1e53c",
"packages": [
{
"name": "doctrine/collections",
@@ -92,6 +92,89 @@
],
"time": "2026-01-15T10:01:58+00:00"
},
+ {
+ "name": "doctrine/data-fixtures",
+ "version": "2.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/data-fixtures.git",
+ "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/7a615ba135e45d67674bb623d90f34f6c7b6bd97",
+ "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/persistence": "^3.1 || ^4.0",
+ "php": "^8.1",
+ "psr/log": "^1.1 || ^2 || ^3"
+ },
+ "conflict": {
+ "doctrine/dbal": "<3.5 || >=5",
+ "doctrine/orm": "<2.14 || >=4",
+ "doctrine/phpcr-odm": "<1.3.0"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^14",
+ "doctrine/dbal": "^3.5 || ^4",
+ "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0",
+ "doctrine/orm": "^2.14 || ^3",
+ "ext-sqlite3": "*",
+ "fig/log-test": "^1",
+ "phpstan/phpstan": "2.1.31",
+ "phpunit/phpunit": "10.5.45 || 12.4.0",
+ "symfony/cache": "^6.4 || ^7",
+ "symfony/var-exporter": "^6.4 || ^7"
+ },
+ "suggest": {
+ "alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)",
+ "doctrine/mongodb-odm": "For loading MongoDB ODM fixtures",
+ "doctrine/orm": "For loading ORM fixtures",
+ "doctrine/phpcr-odm": "For loading PHPCR ODM fixtures"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Common\\DataFixtures\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jonathan Wage",
+ "email": "jonwage@gmail.com"
+ }
+ ],
+ "description": "Data Fixtures for all Doctrine Object Managers",
+ "homepage": "https://www.doctrine-project.org",
+ "keywords": [
+ "database"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/data-fixtures/issues",
+ "source": "https://github.com/doctrine/data-fixtures/tree/2.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://www.doctrine-project.org/sponsorship.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-10-17T20:06:20+00:00"
+ },
{
"name": "doctrine/dbal",
"version": "4.4.1",
@@ -361,6 +444,92 @@
],
"time": "2025-12-24T12:24:29+00:00"
},
+ {
+ "name": "doctrine/doctrine-fixtures-bundle",
+ "version": "4.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/DoctrineFixturesBundle.git",
+ "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/9e013ed10d49bf7746b07204d336384a7d9b5a4d",
+ "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/data-fixtures": "^2.2",
+ "doctrine/doctrine-bundle": "^2.2 || ^3.0",
+ "doctrine/orm": "^2.14.0 || ^3.0",
+ "doctrine/persistence": "^2.4 || ^3.0 || ^4.0",
+ "php": "^8.1",
+ "psr/log": "^2 || ^3",
+ "symfony/config": "^6.4 || ^7.0 || ^8.0",
+ "symfony/console": "^6.4 || ^7.0 || ^8.0",
+ "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0",
+ "symfony/deprecation-contracts": "^2.1 || ^3",
+ "symfony/doctrine-bridge": "^6.4.16 || ^7.1.9 || ^8.0",
+ "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0"
+ },
+ "conflict": {
+ "doctrine/dbal": "< 3"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "14.0.0",
+ "phpstan/phpstan": "2.1.11",
+ "phpunit/phpunit": "^10.5.38 || 11.4.14"
+ },
+ "type": "symfony-bundle",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Bundle\\FixturesBundle\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Doctrine Project",
+ "homepage": "https://www.doctrine-project.org"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony DoctrineFixturesBundle",
+ "homepage": "https://www.doctrine-project.org",
+ "keywords": [
+ "Fixture",
+ "persistence"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues",
+ "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.3.1"
+ },
+ "funding": [
+ {
+ "url": "https://www.doctrine-project.org/sponsorship.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-fixtures-bundle",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-12-03T16:05:42+00:00"
+ },
{
"name": "doctrine/doctrine-migrations-bundle",
"version": "4.0.0",
@@ -7714,175 +7883,6 @@
},
"time": "2026-01-22T09:57:17+00:00"
},
- {
- "name": "doctrine/data-fixtures",
- "version": "2.2.0",
- "source": {
- "type": "git",
- "url": "https://github.com/doctrine/data-fixtures.git",
- "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/7a615ba135e45d67674bb623d90f34f6c7b6bd97",
- "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97",
- "shasum": ""
- },
- "require": {
- "doctrine/persistence": "^3.1 || ^4.0",
- "php": "^8.1",
- "psr/log": "^1.1 || ^2 || ^3"
- },
- "conflict": {
- "doctrine/dbal": "<3.5 || >=5",
- "doctrine/orm": "<2.14 || >=4",
- "doctrine/phpcr-odm": "<1.3.0"
- },
- "require-dev": {
- "doctrine/coding-standard": "^14",
- "doctrine/dbal": "^3.5 || ^4",
- "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0",
- "doctrine/orm": "^2.14 || ^3",
- "ext-sqlite3": "*",
- "fig/log-test": "^1",
- "phpstan/phpstan": "2.1.31",
- "phpunit/phpunit": "10.5.45 || 12.4.0",
- "symfony/cache": "^6.4 || ^7",
- "symfony/var-exporter": "^6.4 || ^7"
- },
- "suggest": {
- "alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)",
- "doctrine/mongodb-odm": "For loading MongoDB ODM fixtures",
- "doctrine/orm": "For loading ORM fixtures",
- "doctrine/phpcr-odm": "For loading PHPCR ODM fixtures"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Doctrine\\Common\\DataFixtures\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Jonathan Wage",
- "email": "jonwage@gmail.com"
- }
- ],
- "description": "Data Fixtures for all Doctrine Object Managers",
- "homepage": "https://www.doctrine-project.org",
- "keywords": [
- "database"
- ],
- "support": {
- "issues": "https://github.com/doctrine/data-fixtures/issues",
- "source": "https://github.com/doctrine/data-fixtures/tree/2.2.0"
- },
- "funding": [
- {
- "url": "https://www.doctrine-project.org/sponsorship.html",
- "type": "custom"
- },
- {
- "url": "https://www.patreon.com/phpdoctrine",
- "type": "patreon"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures",
- "type": "tidelift"
- }
- ],
- "time": "2025-10-17T20:06:20+00:00"
- },
- {
- "name": "doctrine/doctrine-fixtures-bundle",
- "version": "4.3.1",
- "source": {
- "type": "git",
- "url": "https://github.com/doctrine/DoctrineFixturesBundle.git",
- "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/9e013ed10d49bf7746b07204d336384a7d9b5a4d",
- "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d",
- "shasum": ""
- },
- "require": {
- "doctrine/data-fixtures": "^2.2",
- "doctrine/doctrine-bundle": "^2.2 || ^3.0",
- "doctrine/orm": "^2.14.0 || ^3.0",
- "doctrine/persistence": "^2.4 || ^3.0 || ^4.0",
- "php": "^8.1",
- "psr/log": "^2 || ^3",
- "symfony/config": "^6.4 || ^7.0 || ^8.0",
- "symfony/console": "^6.4 || ^7.0 || ^8.0",
- "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0",
- "symfony/deprecation-contracts": "^2.1 || ^3",
- "symfony/doctrine-bridge": "^6.4.16 || ^7.1.9 || ^8.0",
- "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0"
- },
- "conflict": {
- "doctrine/dbal": "< 3"
- },
- "require-dev": {
- "doctrine/coding-standard": "14.0.0",
- "phpstan/phpstan": "2.1.11",
- "phpunit/phpunit": "^10.5.38 || 11.4.14"
- },
- "type": "symfony-bundle",
- "autoload": {
- "psr-4": {
- "Doctrine\\Bundle\\FixturesBundle\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
- },
- {
- "name": "Doctrine Project",
- "homepage": "https://www.doctrine-project.org"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Symfony DoctrineFixturesBundle",
- "homepage": "https://www.doctrine-project.org",
- "keywords": [
- "Fixture",
- "persistence"
- ],
- "support": {
- "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues",
- "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.3.1"
- },
- "funding": [
- {
- "url": "https://www.doctrine-project.org/sponsorship.html",
- "type": "custom"
- },
- {
- "url": "https://www.patreon.com/phpdoctrine",
- "type": "patreon"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-fixtures-bundle",
- "type": "tidelift"
- }
- ],
- "time": "2025-12-03T16:05:42+00:00"
- },
{
"name": "evenement/evenement",
"version": "v3.0.2",
diff --git a/backend/config/bundles.php b/backend/config/bundles.php
index 61cc5f3..2a67cb2 100644
--- a/backend/config/bundles.php
+++ b/backend/config/bundles.php
@@ -7,7 +7,7 @@
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
- Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
+ Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
diff --git a/build/nginx/Dockerfile b/build/nginx/Dockerfile
new file mode 100644
index 0000000..1b720b5
--- /dev/null
+++ b/build/nginx/Dockerfile
@@ -0,0 +1,29 @@
+FROM node:22-alpine AS frontend
+
+WORKDIR /frontend
+
+COPY frontend .
+
+RUN npm install && npm run build
+
+# The vite config outputs to ../backend/public/build from /frontend context
+# That path resolves to /backend/public/build, copy it to /dist
+RUN cp -r /backend/public/build /dist
+
+# -----------------------------------
+# nginx-Stage (serve frontend + proxy API)
+# -----------------------------------
+FROM nginx:stable-alpine
+
+# Create public directory structure
+RUN mkdir -p /var/www/project/public/build && \
+ chmod -R 755 /var/www/project/public
+
+# Copy built frontend assets
+COPY --from=frontend /dist /var/www/project/public/build
+
+# Copy EasyAdmin bundles from backend source
+COPY backend/public/bundles /var/www/project/public/bundles
+
+# Copy nginx configuration
+COPY build/nginx/default.conf /etc/nginx/conf.d/default.conf
diff --git a/build/php/Dockerfile b/build/php/Dockerfile
index 187e3be..6c6f7c2 100644
--- a/build/php/Dockerfile
+++ b/build/php/Dockerfile
@@ -59,6 +59,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
+# -----------------------------------
+# Frontend-Stage (build React app)
+# -----------------------------------
+FROM node:22-alpine AS frontend
+
+WORKDIR /frontend
+
+COPY frontend .
+
+RUN npm install && npm run build
+
+# The vite config builds to ../backend/public/build which is /backend/public/build in this context
+# Copy it to /dist for the prod stage to access it
+RUN cp -r /backend/public/build /dist
+
# -----------------------------------
# Production-Stage (no Composer, no XDebug)
# Note: from vendor!
@@ -66,15 +81,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
FROM base AS prod
COPY --from=vendor /var/www/project/vendor ./vendor
+COPY --from=frontend /dist ./public/build
COPY backend/.env .
COPY backend/bin ./bin
+COPY backend/composer.json .
COPY backend/config ./config
COPY backend/migrations ./migrations
COPY backend/public ./public
COPY backend/src ./src
COPY backend/templates ./templates
-#RUN mkdir -p var/cache var/log && chmod -R 777 var
-# The following might not be required once we have the shared cache - check it!
-#RUN mkdir var && chmod -R 777 var
+RUN mkdir -p var/cache var/log && chmod -R 777 var && \
+ sed -i 's/^APP_ENV=.*/APP_ENV=prod/' .env
diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts
new file mode 100644
index 0000000..c35f361
--- /dev/null
+++ b/frontend/src/api/config.ts
@@ -0,0 +1,31 @@
+/**
+ * Get the API base URL based on the current environment.
+ *
+ * In development (Vite on localhost:5173):
+ * - Returns http://localhost:8080 (backend on different port)
+ *
+ * In production (deployed app):
+ * - Returns the same origin as the frontend
+ * - Works when frontend and backend are served from same domain/port
+ */
+export function getApiBaseUrl(): string {
+ const currentOrigin = window.location.origin
+
+ // Development: Frontend on localhost:5173, backend on localhost:8080
+ if (currentOrigin.includes('5173')) {
+ return 'http://localhost:8080'
+ }
+
+ // Production/Docker: Frontend and backend share origin
+ return currentOrigin
+}
+
+/**
+ * Get the full API endpoint URL
+ */
+export function getApiUrl(path: string): string {
+ const baseUrl = getApiBaseUrl()
+ // Ensure path starts with /
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`
+ return `${baseUrl}${normalizedPath}`
+}
diff --git a/frontend/src/features/auth/AuthContext.tsx b/frontend/src/features/auth/AuthContext.tsx
index aa099db..2b333a2 100644
--- a/frontend/src/features/auth/AuthContext.tsx
+++ b/frontend/src/features/auth/AuthContext.tsx
@@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { logout as apiLogout } from './api'
+import { getApiBaseUrl, getApiUrl } from '../../api/config'
interface User {
email: string
@@ -13,17 +14,6 @@ interface AuthContextType {
const AuthContext = createContext(undefined)
-// Get the API base URL - use backend URL if frontend is on different origin
-const getApiBaseUrl = () => {
- const currentOrigin = window.location.origin
- // If we're on localhost:5173 (Vite dev), point to backend at 8080
- if (currentOrigin.includes('5173')) {
- return 'http://localhost:8080'
- }
- // Otherwise use current origin
- return currentOrigin
-}
-
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null)
const [isLoading, setIsLoading] = useState(true)
@@ -32,8 +22,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Check server session on app load
const checkAuth = async () => {
try {
- const apiUrl = getApiBaseUrl()
- const response = await fetch(`${apiUrl}/api/me`, {
+ const response = await fetch(getApiUrl('/api/me'), {
method: 'GET',
credentials: 'include', // Include cookies for session auth
headers: {
@@ -47,7 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} else {
setUser(null)
// Redirect to login if not authenticated
- window.location.href = `${apiUrl}/login`
+ window.location.href = `${getApiBaseUrl()}/login`
}
} catch (error) {
console.error('Failed to check authentication:', error)
@@ -68,8 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.error('Error during logout:', error)
} finally {
// Redirect to login page
- const apiUrl = getApiBaseUrl()
- window.location.href = `${apiUrl}/login`
+ window.location.href = `${getApiBaseUrl()}/login`
}
}
@@ -93,5 +81,3 @@ export function useAuth() {
}
return context
}
-
-
diff --git a/frontend/src/features/auth/api.ts b/frontend/src/features/auth/api.ts
index b21984c..ccde369 100644
--- a/frontend/src/features/auth/api.ts
+++ b/frontend/src/features/auth/api.ts
@@ -1,10 +1,9 @@
+import { getApiUrl } from '../../api/config'
+
// Logout API function - handles destroying session on server
export async function logout(): Promise {
- // Get the API base URL - use backend URL if frontend is on different origin
- const apiUrl = getApiBaseUrl()
-
try {
- const response = await fetch(`${apiUrl}/api/logout`, {
+ const response = await fetch(getApiUrl('/api/logout'), {
method: 'POST',
credentials: 'include',
})
@@ -16,18 +15,3 @@ export async function logout(): Promise {
console.error('Failed to logout:', error)
}
}
-
-// Get the API base URL - use backend URL if frontend is on different origin
-function getApiBaseUrl(): string {
- const currentOrigin = window.location.origin
- // If we're on localhost:5173 (Vite dev), point to backend at 8080
- if (currentOrigin.includes('5173')) {
- return 'http://localhost:8080'
- }
- // Otherwise use current origin
- return currentOrigin
-}
-
-
-
-
diff --git a/frontend/src/features/calculation/api.ts b/frontend/src/features/calculation/api.ts
index 6aaf0ab..e8d1abb 100644
--- a/frontend/src/features/calculation/api.ts
+++ b/frontend/src/features/calculation/api.ts
@@ -1,3 +1,5 @@
+import { getApiUrl } from '../../api/config'
+
interface Price {
value: number
currency: string
@@ -25,7 +27,7 @@ export interface CalculationResponse {
}
export async function fetchCalculation(): Promise {
- const response = await fetch('http://localhost:8080/api/calculate', {
+ const response = await fetch(getApiUrl('/api/calculate'), {
credentials: 'include',
})
@@ -38,7 +40,7 @@ export async function fetchCalculation(): Promise {
}
export async function downloadCalculationReport(): Promise {
- return fetch('http://localhost:8080/api/report/calculation', {
+ return fetch(getApiUrl('/api/report/calculation'), {
credentials: 'include',
})
}
diff --git a/frontend/src/features/expense/api.ts b/frontend/src/features/expense/api.ts
index 80e32e9..b4a3fe1 100644
--- a/frontend/src/features/expense/api.ts
+++ b/frontend/src/features/expense/api.ts
@@ -1,3 +1,5 @@
+import { getApiUrl } from '../../api/config'
+
interface Price {
value: number
currency: string
@@ -11,7 +13,7 @@ interface ExpenseData {
}
export async function trackExpense(expense: ExpenseData): Promise {
- const response = await fetch('http://localhost:8080/api/track', {
+ const response = await fetch(getApiUrl('/api/track'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
diff --git a/helm/Chart.yaml b/helm/Chart.yaml
new file mode 100644
index 0000000..07763e1
--- /dev/null
+++ b/helm/Chart.yaml
@@ -0,0 +1,14 @@
+apiVersion: v2
+name: split-fairly
+description: Helm chart for the split-fairly application
+type: application
+version: 0.1.0
+appVersion: "0.1.1"
+keywords:
+ - split-fairly
+ - web
+ - php
+ - mysql
+maintainers:
+ - name: Martin Komischke
+ email: "martin@example.com"
diff --git a/helm/templates/NOTES.txt b/helm/templates/NOTES.txt
new file mode 100644
index 0000000..0257be5
--- /dev/null
+++ b/helm/templates/NOTES.txt
@@ -0,0 +1,88 @@
+================================================================================
+{{ include "split-fairly.fullname" . | upper }} - Deployment Complete
+================================================================================
+{{ if eq .Values.service.web.type "NodePort" }}
+🚀 Direct Access (NodePort):
+ The application is available at:
+ → http://localhost:{{ .Values.service.web.nodePort }}
+ → http://localhost:{{ .Values.service.web.nodePort }}/admin
+
+ This works on Docker Desktop and Minikube without port-forwarding.
+{{ else if eq .Values.service.web.type "LoadBalancer" }}
+🚀 LoadBalancer Access:
+ Wait for an external IP to be assigned (may take a moment):
+ $ kubectl get svc {{ include "split-fairly.fullname" . }}-web
+
+ Once assigned, visit:
+ → http://:{{ .Values.service.web.port }}
+ → http://:{{ .Values.service.web.port }}/admin
+{{ end }}
+================================================================================
+📌 Alternative Access Methods:
+
+1. Using kubectl port-forward (any service type):
+ $ kubectl port-forward svc/{{ include "split-fairly.fullname" . }}-web 8080:80
+ → http://localhost:8080
+
+2. Using NodePort directly (if service type is NodePort):
+ $ kubectl get svc {{ include "split-fairly.fullname" . }}-web
+ → Access via the listed NodePort (default: {{ .Values.service.web.nodePort }})
+
+3. Inside the cluster:
+ → http://{{ include "split-fairly.fullname" . }}-web:{{ .Values.service.web.port }}
+
+================================================================================
+🔐 Initial Login:
+ Email: {{ .Values.env.ADMIN_EMAIL }}
+ Password: {{ .Values.env.ADMIN_PASSWORD }}
+
+================================================================================
+📊 Service Information:
+ Release: {{ .Release.Name }}
+ Chart: {{ .Chart.Name }}-{{ .Chart.Version }}
+ Namespace: {{ .Release.Namespace }}
+ Backend URL: http://{{ include "split-fairly.fullname" . }}-app:{{ .Values.service.app.port }}
+ Database: {{ include "split-fairly.fullname" . }}-db:{{ .Values.service.mysql.port }}
+
+================================================================================
+🗄️ Database Initialization:
+ The database schema and initial fixtures (admin user) are managed by a
+ separate Kubernetes Job ({{ include "split-fairly.fullname" . }}-db-init).
+
+ ✓ The Job runs once automatically after deployment
+ ✓ App and worker pods wait for the Job to complete before starting
+ ✓ The Job is idempotent - it safely handles already-existing data
+
+ Check job status:
+ $ kubectl get job {{ include "split-fairly.fullname" . }}-db-init
+
+ View job logs:
+ $ kubectl logs job/{{ include "split-fairly.fullname" . }}-db-init
+
+ 🔄 To re-initialize the database:
+ 1. Delete the existing database:
+ $ kubectl delete statefulset {{ include "split-fairly.fullname" . }}-db
+ $ kubectl delete pvc data-{{ include "split-fairly.fullname" . }}-db-0
+
+ 2. Wait for the MySQL pod to restart and become ready
+
+ 3. Delete the completed Job to trigger re-initialization:
+ $ kubectl delete job {{ include "split-fairly.fullname" . }}-db-init
+
+ 4. Redeploy the app (triggers Job recreation):
+ $ helm upgrade {{ .Release.Name }} ./helm
+
+================================================================================
+📋 Useful Commands:
+
+ Check all resources:
+ $ kubectl get all
+
+ View deployment logs:
+ $ kubectl logs deployment/{{ include "split-fairly.fullname" . }}-app
+ $ kubectl logs deployment/{{ include "split-fairly.fullname" . }}-worker
+
+ Describe pods (for troubleshooting):
+ $ kubectl describe pod -l app={{ include "split-fairly.fullname" . }}-app
+
+================================================================================
diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl
new file mode 100644
index 0000000..aefa871
--- /dev/null
+++ b/helm/templates/_helpers.tpl
@@ -0,0 +1,3 @@
+{{- define "split-fairly.fullname" -}}
+{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
+{{- end -}}
diff --git a/helm/templates/configmap-nginx.yaml b/helm/templates/configmap-nginx.yaml
new file mode 100644
index 0000000..439271b
--- /dev/null
+++ b/helm/templates/configmap-nginx.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ include "split-fairly.fullname" . }}-nginx
+data:
+ default.conf: |-
+{{ tpl .Values.webConfig . | indent 4 }}
diff --git a/helm/templates/deployment-app.yaml b/helm/templates/deployment-app.yaml
new file mode 100644
index 0000000..5f94c5d
--- /dev/null
+++ b/helm/templates/deployment-app.yaml
@@ -0,0 +1,72 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "split-fairly.fullname" . }}-app
+ labels:
+ app.kubernetes.io/name: {{ .Chart.Name }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+spec:
+ replicas: {{ .Values.replicaCount.app }}
+ selector:
+ matchLabels:
+ app: {{ include "split-fairly.fullname" . }}-app
+ template:
+ metadata:
+ labels:
+ app: {{ include "split-fairly.fullname" . }}-app
+ spec:
+ serviceAccountName: {{ include "split-fairly.fullname" . }}
+ initContainers:
+ - name: wait-for-db-init
+ image: bitnami/kubectl:latest
+ command:
+ - sh
+ - -c
+ - |
+ echo "Waiting for database initialization job to complete..."
+ until kubectl get job {{ include "split-fairly.fullname" . }}-db-init -o jsonpath='{.status.succeeded}' 2>/dev/null | grep -q '1'; do
+ STATUS=$(kubectl get job {{ include "split-fairly.fullname" . }}-db-init -o jsonpath='{.status.conditions[?(@.type=="Complete")].status}' 2>/dev/null || echo "NotFound")
+ if [ "$STATUS" = "True" ]; then
+ echo "Database initialization completed successfully!"
+ exit 0
+ fi
+ echo "Database initialization job not yet completed. Status: $STATUS"
+ sleep 2
+ done
+ echo "Database initialization completed successfully!"
+ containers:
+ - name: app
+ image: "{{ .Values.image.app.repository }}:{{ .Values.image.app.tag }}"
+ imagePullPolicy: {{ .Values.image.app.pullPolicy }}
+ ports:
+ - containerPort: {{ .Values.service.app.port }}
+ env:
+ - name: APP_SECRET
+ value: "{{ .Values.env.APP_SECRET }}"
+ - name: APP_ENV
+ value: "{{ .Values.env.APP_ENV }}"
+ - name: APP_VERSION
+ value: "{{ .Values.env.APP_VERSION }}"
+ - name: DATABASE_URL
+ value: "{{ tpl .Values.env.DATABASE_URL . }}"
+ - name: ADMIN_EMAIL
+ value: "{{ .Values.env.ADMIN_EMAIL }}"
+ - name: ADMIN_PASSWORD
+ value: "{{ .Values.env.ADMIN_PASSWORD }}"
+ volumeMounts:
+ - name: app-var
+ mountPath: /var/www/project/var
+ livenessProbe:
+ tcpSocket:
+ port: {{ .Values.service.app.port }}
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ readinessProbe:
+ tcpSocket:
+ port: {{ .Values.service.app.port }}
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ resources: {{ toYaml .Values.resources.app | nindent 12 }}
+ volumes:
+ - name: app-var
+ emptyDir: {}
diff --git a/helm/templates/deployment-web.yaml b/helm/templates/deployment-web.yaml
new file mode 100644
index 0000000..47211fb
--- /dev/null
+++ b/helm/templates/deployment-web.yaml
@@ -0,0 +1,32 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "split-fairly.fullname" . }}-web
+ labels:
+ app.kubernetes.io/name: {{ .Chart.Name }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+spec:
+ replicas: {{ .Values.replicaCount.web }}
+ selector:
+ matchLabels:
+ app: {{ include "split-fairly.fullname" . }}-web
+ template:
+ metadata:
+ labels:
+ app: {{ include "split-fairly.fullname" . }}-web
+ spec:
+ containers:
+ - name: nginx
+ image: "{{ .Values.image.web.repository }}:{{ .Values.image.web.tag }}"
+ imagePullPolicy: {{ .Values.image.web.pullPolicy }}
+ ports:
+ - containerPort: 80
+ volumeMounts:
+ - name: nginx-config
+ mountPath: /etc/nginx/conf.d/default.conf
+ subPath: default.conf
+ resources: {{ toYaml .Values.resources.web | nindent 12 }}
+ volumes:
+ - name: nginx-config
+ configMap:
+ name: {{ include "split-fairly.fullname" . }}-nginx
diff --git a/helm/templates/deployment-worker.yaml b/helm/templates/deployment-worker.yaml
new file mode 100644
index 0000000..505de2d
--- /dev/null
+++ b/helm/templates/deployment-worker.yaml
@@ -0,0 +1,53 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "split-fairly.fullname" . }}-worker
+ labels:
+ app.kubernetes.io/name: {{ .Chart.Name }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+spec:
+ replicas: {{ .Values.replicaCount.worker }}
+ selector:
+ matchLabels:
+ app: {{ include "split-fairly.fullname" . }}-worker
+ template:
+ metadata:
+ labels:
+ app: {{ include "split-fairly.fullname" . }}-worker
+ spec:
+ serviceAccountName: {{ include "split-fairly.fullname" . }}
+ initContainers:
+ - name: wait-for-db-init
+ image: bitnami/kubectl:latest
+ command:
+ - sh
+ - -c
+ - |
+ echo "Waiting for database initialization job to complete..."
+ until kubectl get job {{ include "split-fairly.fullname" . }}-db-init -o jsonpath='{.status.succeeded}' 2>/dev/null | grep -q '1'; do
+ STATUS=$(kubectl get job {{ include "split-fairly.fullname" . }}-db-init -o jsonpath='{.status.conditions[?(@.type=="Complete")].status}' 2>/dev/null || echo "NotFound")
+ if [ "$STATUS" = "True" ]; then
+ echo "Database initialization completed successfully!"
+ exit 0
+ fi
+ echo "Database initialization job not yet completed. Status: $STATUS"
+ sleep 2
+ done
+ echo "Database initialization completed successfully!"
+ containers:
+ - name: worker
+ image: "{{ .Values.image.app.repository }}:{{ .Values.image.app.tag }}"
+ imagePullPolicy: {{ .Values.image.app.pullPolicy }}
+ command: ["bin/console", "messenger:consume", "--all", "-vv"]
+ env:
+ - name: APP_ENV
+ value: "{{ .Values.env.APP_ENV }}"
+ - name: APP_VERSION
+ value: "{{ .Values.env.APP_VERSION }}"
+ - name: DATABASE_URL
+ value: "{{ tpl .Values.env.DATABASE_URL . }}"
+ - name: ADMIN_EMAIL
+ value: "{{ .Values.env.ADMIN_EMAIL }}"
+ - name: ADMIN_PASSWORD
+ value: "{{ .Values.env.ADMIN_PASSWORD }}"
+ resources: {{ toYaml .Values.resources.worker | nindent 12 }}
diff --git a/helm/templates/job-db-init.yaml b/helm/templates/job-db-init.yaml
new file mode 100644
index 0000000..f4c06f3
--- /dev/null
+++ b/helm/templates/job-db-init.yaml
@@ -0,0 +1,75 @@
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: {{ include "split-fairly.fullname" . }}-db-init
+ labels:
+ app.kubernetes.io/name: {{ .Chart.Name }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+spec:
+ # Run only once - must be manually deleted and recreated to run again
+ backoffLimit: 3
+ ttlSecondsAfterFinished: null
+ template:
+ metadata:
+ labels:
+ app: {{ include "split-fairly.fullname" . }}-db-init
+ spec:
+ serviceAccountName: default
+ restartPolicy: Never
+ # Wait for MySQL to be ready
+ initContainers:
+ - name: wait-for-db
+ image: busybox:1.35
+ command:
+ - sh
+ - -c
+ - |
+ echo "Waiting for MySQL to be ready..."
+ until nc -z {{ include "split-fairly.fullname" . }}-db {{ .Values.service.mysql.port }}; do
+ echo "MySQL not ready, waiting..."
+ sleep 2
+ done
+ echo "MySQL is ready!"
+ containers:
+ - name: db-init
+ image: "{{ .Values.image.app.repository }}:{{ .Values.image.app.tag }}"
+ imagePullPolicy: {{ .Values.image.app.pullPolicy }}
+ env:
+ - name: APP_ENV
+ value: "{{ .Values.env.APP_ENV }}"
+ - name: APP_SECRET
+ value: "{{ .Values.env.APP_SECRET }}"
+ - name: DATABASE_URL
+ value: "{{ tpl .Values.env.DATABASE_URL . }}"
+ - name: ADMIN_EMAIL
+ value: "{{ .Values.env.ADMIN_EMAIL }}"
+ - name: ADMIN_PASSWORD
+ value: "{{ .Values.env.ADMIN_PASSWORD }}"
+ command:
+ - sh
+ - -c
+ - |
+ set -e
+ echo "================================================"
+ echo "Starting Database Initialization"
+ echo "================================================"
+
+ echo "1. Creating database if not exists..."
+ php bin/console doctrine:database:create --if-not-exists
+
+ echo "2. Creating database schema (if not exists)..."
+ php bin/console doctrine:schema:create 2>/dev/null || echo "Schema already exists or error - continuing..."
+
+ echo "3. Loading fixtures (admin user)..."
+ # Only load fixtures if admin user doesn't exist
+ ADMIN_EXISTS=$(php bin/console doctrine:query:dql "SELECT COUNT(u) FROM App\\Entity\\User u WHERE u.email = '{{ .Values.env.ADMIN_EMAIL }}'" 2>/dev/null | grep -oP '\d+' | head -1 || echo "0")
+ if [ "$ADMIN_EXISTS" = "0" ]; then
+ echo " Admin user does not exist - loading fixtures..."
+ php bin/console doctrine:fixtures:load --no-interaction --append
+ else
+ echo " Admin user already exists - skipping fixture load"
+ fi
+
+ echo "================================================"
+ echo "Database initialization completed successfully!"
+ echo "================================================"
diff --git a/helm/templates/role.yaml b/helm/templates/role.yaml
new file mode 100644
index 0000000..966c5ab
--- /dev/null
+++ b/helm/templates/role.yaml
@@ -0,0 +1,28 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+ name: {{ include "split-fairly.fullname" . }}-read-jobs
+ labels:
+ app.kubernetes.io/name: {{ .Chart.Name }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+rules:
+- apiGroups: ["batch"]
+ resources: ["jobs"]
+ verbs: ["get", "list"]
+
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: {{ include "split-fairly.fullname" . }}-read-jobs-binding
+ labels:
+ app.kubernetes.io/name: {{ .Chart.Name }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: Role
+ name: {{ include "split-fairly.fullname" . }}-read-jobs
+subjects:
+- kind: ServiceAccount
+ name: {{ include "split-fairly.fullname" . }}
+ namespace: {{ .Release.Namespace }}
diff --git a/helm/templates/service-app.yaml b/helm/templates/service-app.yaml
new file mode 100644
index 0000000..49161fd
--- /dev/null
+++ b/helm/templates/service-app.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "split-fairly.fullname" . }}-app
+spec:
+ type: ClusterIP
+ selector:
+ app: {{ include "split-fairly.fullname" . }}-app
+ ports:
+ - port: {{ .Values.service.app.port }}
+ targetPort: {{ .Values.service.app.port }}
+ protocol: TCP
+ name: http
diff --git a/helm/templates/service-db.yaml b/helm/templates/service-db.yaml
new file mode 100644
index 0000000..0938a6c
--- /dev/null
+++ b/helm/templates/service-db.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "split-fairly.fullname" . }}-db
+spec:
+ type: ClusterIP
+ selector:
+ app: {{ include "split-fairly.fullname" . }}-db
+ ports:
+ - port: {{ .Values.service.mysql.port }}
+ targetPort: 3306
+ protocol: TCP
+ name: mysql
diff --git a/helm/templates/service-web.yaml b/helm/templates/service-web.yaml
new file mode 100644
index 0000000..0034886
--- /dev/null
+++ b/helm/templates/service-web.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "split-fairly.fullname" . }}-web
+spec:
+ type: {{ .Values.service.web.type }}
+ selector:
+ app: {{ include "split-fairly.fullname" . }}-web
+ ports:
+ - port: {{ .Values.service.web.port }}
+ targetPort: 80
+ protocol: TCP
+ name: http
+ nodePort: {{ .Values.service.web.nodePort }}
diff --git a/helm/templates/serviceaccount.yaml b/helm/templates/serviceaccount.yaml
new file mode 100644
index 0000000..e5412e4
--- /dev/null
+++ b/helm/templates/serviceaccount.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ include "split-fairly.fullname" . }}
+ labels:
+ app.kubernetes.io/name: {{ .Chart.Name }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
diff --git a/helm/templates/statefulset-db.yaml b/helm/templates/statefulset-db.yaml
new file mode 100644
index 0000000..81f609a
--- /dev/null
+++ b/helm/templates/statefulset-db.yaml
@@ -0,0 +1,57 @@
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: {{ include "split-fairly.fullname" . }}-db
+spec:
+ selector:
+ matchLabels:
+ app: {{ include "split-fairly.fullname" . }}-db
+ serviceName: {{ include "split-fairly.fullname" . }}-db
+ replicas: 1
+ template:
+ metadata:
+ labels:
+ app: {{ include "split-fairly.fullname" . }}-db
+ spec:
+ containers:
+ - name: mysql
+ image: "{{ .Values.image.mysql.repository }}:{{ .Values.image.mysql.tag }}"
+ imagePullPolicy: {{ .Values.image.mysql.pullPolicy }}
+ env:
+ - name: MYSQL_ROOT_PASSWORD
+ value: "{{ .Values.mysql.rootPassword }}"
+ - name: MYSQL_PASSWORD
+ value: "{{ .Values.mysql.password }}"
+ ports:
+ - containerPort: 3306
+ name: mysql
+ readinessProbe:
+ exec:
+ command:
+ - sh
+ - -c
+ - mysqladmin ping -h 127.0.0.1 --silent
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ resources: {{ toYaml .Values.resources.mysql | nindent 12 }}
+ volumeMounts:
+ - name: mysql-data
+ mountPath: /var/lib/mysql
+{{- if not .Values.mysql.persistence.enabled }}
+ volumes:
+ - name: mysql-data
+ emptyDir: {}
+{{- end }}
+{{- if .Values.mysql.persistence.enabled }}
+ volumeClaimTemplates:
+ - metadata:
+ name: mysql-data
+ spec:
+ accessModes: [ "ReadWriteOnce" ]
+ resources:
+ requests:
+ storage: {{ .Values.mysql.persistence.size }}
+{{- if .Values.mysql.persistence.storageClass }}
+ storageClassName: "{{ .Values.mysql.persistence.storageClass }}"
+{{- end }}
+{{- end }}
diff --git a/helm/values.yaml b/helm/values.yaml
new file mode 100644
index 0000000..3585fc1
--- /dev/null
+++ b/helm/values.yaml
@@ -0,0 +1,112 @@
+replicaCount:
+ app: 1
+ worker: 1
+ web: 1
+
+image:
+ app:
+ repository: "split-fairly"
+ tag: "0.1.1"
+ pullPolicy: IfNotPresent
+ web:
+ repository: "split-fairly-web"
+ tag: "0.1.1"
+ pullPolicy: IfNotPresent
+ mysql:
+ repository: "mysql"
+ tag: "8"
+ pullPolicy: IfNotPresent
+
+service:
+ web:
+ type: NodePort
+ port: 80
+ nodePort: 30190
+ app:
+ port: 9000
+ mysql:
+ port: 3306
+
+resources:
+ app: {}
+ worker: {}
+ web: {}
+ mysql: {}
+
+env:
+ APP_SECRET: "F3E46580-5F9A-4524-B218-90490B033192"
+ APP_ENV: "prod"
+ APP_VERSION: "0.1.1"
+ DATABASE_URL: "mysql://{{ .Values.mysql.user }}:{{ .Values.mysql.password }}@{{ include \"split-fairly.fullname\" . }}-db:{{ .Values.service.mysql.port }}/{{ default \"app\" .Values.mysql.database }}?serverVersion=8.0.31&charset=utf8mb4"
+ ADMIN_EMAIL: "admin@example.com"
+ ADMIN_PASSWORD: "secret"
+
+mysql:
+ rootPassword: "secret"
+ password: "secret"
+ user: "root"
+ persistence:
+ enabled: true
+ size: 8Gi
+ storageClass: ""
+
+webConfig: |
+ server {
+ listen 80;
+ server_name localhost;
+
+ root /var/www/project/public;
+ index index.php index.html;
+
+ # Serve built frontend assets with long cache headers
+ location /build/ {
+ alias /var/www/project/public/build/;
+ access_log off;
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ }
+
+ # Static assets
+ location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
+ try_files $uri =404;
+ access_log off;
+ add_header Cache-Control "public, max-age=86400";
+ }
+
+ # SPA and app entrypoint - serve built index.html for SPA
+ location = / {
+ rewrite ^ /build/index.html break;
+ }
+
+ # API endpoints - directly proxy to PHP
+ location ^~ /api/ {
+ fastcgi_pass {{ include "split-fairly.fullname" . }}-app:9000;
+ fastcgi_split_path_info ^(.+\.php)(/.*)$;
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME $document_root/index.php;
+ fastcgi_param SCRIPT_NAME /index.php;
+ fastcgi_param REQUEST_URI $request_uri;
+ fastcgi_param PATH_INFO $uri;
+ fastcgi_param DOCUMENT_ROOT $document_root;
+ fastcgi_param HTTPS off;
+ }
+
+ location / {
+ try_files $uri $uri/ /index.php$is_args$args;
+ }
+
+ # PHP-FPM handling for internal redirect to index.php
+ location ~ ^/index\.php(/|$) {
+ fastcgi_pass {{ include "split-fairly.fullname" . }}-app:9000;
+ fastcgi_split_path_info ^(.+\.php)(/.*)$;
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+ fastcgi_param DOCUMENT_ROOT $document_root;
+ fastcgi_param HTTPS off;
+ internal;
+ }
+
+ # Deny direct .php access
+ location ~ \.php$ {
+ return 404;
+ }
+ }