diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..b9a9226
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,42 @@
+# Generated by GitHub Copilot
+#
+# ci.yml — Build and test the calorie-counter backend
+#
+# Runs on every push to main and every PR targeting main.
+# Produces a green/red signal that docker.yml waits for before building images.
+#
+# Runs on pi-runner (native ARM64) — no QEMU, no cross-compilation overhead.
+
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+# Cancel any in-progress CI run for the same ref so the single-capacity Pi
+# runner is not blocked by a superseded run.
+concurrency:
+ group: ci-${{ gitea.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: Build & test backend
+ runs-on: pi-runner
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Java 21 LTS
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '21'
+ cache: maven
+
+ - name: Run tests
+ working-directory: backend
+ run: mvn -q -ntp clean verify
diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml
new file mode 100644
index 0000000..3953f74
--- /dev/null
+++ b/.gitea/workflows/docker.yml
@@ -0,0 +1,168 @@
+# Generated by GitHub Copilot
+#
+# docker.yml — Build and push Docker image to Gitea Container Registry
+#
+# Triggers after CI passes on main (workflow_run) to prevent this heavyweight job
+# from competing with the Maven+test job on the single-capacity Pi runner.
+#
+# Produces images tagged with the short SHA and 'latest'.
+# Immediately deploys to the Pi production stack via docker.sock.
+#
+# Required Gitea repository secrets (Settings → Actions → Secrets):
+# REGISTRY_USERNAME — Gitea username (andrisenins)
+# REGISTRY_PASSWORD — Gitea access token (package:write + repo:write scope)
+
+name: Docker
+
+on:
+ # Only run after CI succeeds — prevents OOM kills on the Pi from parallel jobs.
+ workflow_run:
+ workflows: ["CI"]
+ types: [completed]
+ branches: [main]
+ workflow_dispatch:
+
+# Cancel any in-progress docker build for the same branch so stale BuildKit
+# containers from the previous run are cleaned up before the new build starts.
+concurrency:
+ group: docker-${{ gitea.event.workflow_run.head_branch || gitea.ref }}
+ cancel-in-progress: true
+
+env:
+ REGISTRY: git.amlab.dev
+
+jobs:
+ build-api:
+ name: Build & push API image
+ runs-on: pi-runner
+ # For workflow_run events: only build if CI succeeded.
+ # For workflow_dispatch (manual): always run.
+ if: >
+ github.event_name == 'workflow_dispatch' ||
+ github.event.workflow_run.conclusion == 'success'
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ # In a workflow_run context, checkout the commit that triggered CI.
+ ref: ${{ gitea.event.workflow_run.head_sha || gitea.sha }}
+
+ - name: Log in to Gitea Container Registry
+ # Login BEFORE the Maven build while the Pi CPU is idle.
+ # When login is attempted after a long Maven build the host Docker daemon
+ # exhausts its 30s HTTP timeout on every attempt (confirmed in MYPHOTOS
+ # task logs — same issue applies here).
+ # NOTE: use 'if' not '&&' — act_runner uses 'bash -e', so 'cmd && break'
+ # exits the entire script on the first non-zero cmd result.
+ env:
+ REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
+ REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
+ run: |
+ for i in 1 2 3 4 5 6 7 8 9 10; do
+ if echo "$REGISTRY_PASSWORD" | docker login --username "$REGISTRY_USERNAME" --password-stdin "$REGISTRY"; then
+ break
+ fi
+ if [ "$i" -eq 10 ]; then
+ echo "ERROR: All 10 docker login attempts failed"
+ exit 1
+ fi
+ echo "Login attempt $i failed, retrying in 30s..."
+ sleep 30
+ done
+
+ # Build JAR natively (inside the job container, outside BuildKit/QEMU).
+ # Without this, Maven runs under QEMU cross-emulation during the Docker build
+ # RUN step, causing TLS connections to Maven Central to break after ~20 min.
+ - name: Set up Java 21 LTS
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '21'
+ cache: maven
+
+ - name: Build JAR (native — avoids QEMU Maven download)
+ working-directory: backend
+ run: mvn -q -ntp clean package -DskipTests
+
+ - name: Build and push API image
+ # Use plain docker build+push (no buildx, no JS action) to avoid
+ # docker/build-push-action@v6 crashing silently in act_runner v0.5.0.
+ # DOCKER_BUILDKIT=0 uses legacy builder — no gRPC DeadlineExceeded on RPi.
+ env:
+ DOCKER_BUILDKIT: "0"
+ SHA: ${{ gitea.event.workflow_run.head_sha || gitea.sha }}
+ OWNER: ${{ gitea.repository_owner }}
+ run: |
+ SHORT_SHA="${SHA:0:7}"
+ IMAGE="${REGISTRY}/${OWNER}/calorie-counter-api"
+ docker build \
+ --target ci-image \
+ --build-arg JAR_FILE=target/calorie-counter-backend-1.0.0-SNAPSHOT.jar \
+ -t "${IMAGE}:${SHORT_SHA}" \
+ -t "${IMAGE}:latest" \
+ backend/
+ docker push "${IMAGE}:${SHORT_SHA}"
+ docker push "${IMAGE}:latest"
+
+ deploy:
+ name: Deploy to Pi — pull & restart stack
+ runs-on: pi-runner
+ needs: [build-api]
+ if: >
+ github.event_name == 'workflow_dispatch' ||
+ github.event.workflow_run.conclusion == 'success'
+
+ steps:
+ - name: Pull and restart production stack
+ # Run docker compose against the Pi HOST's homelab directory.
+ # Volume paths in a `docker run` issued via the mounted socket are
+ # resolved against the Docker DAEMON's filesystem (the Pi host), not
+ # the job container's filesystem — standard DinD-via-socket behaviour.
+ # The .env file in that directory supplies all production secrets so
+ # no secrets need to be passed into this job.
+ env:
+ REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
+ REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
+ run: |
+ docker run --rm \
+ -v /home/andris/homelab/calorie-counter:/project \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -e REGISTRY_PASSWORD \
+ -e REGISTRY_USERNAME \
+ -e "REGISTRY=${REGISTRY}" \
+ -w /project \
+ docker:27.5.1-cli \
+ sh -c '
+ echo "$REGISTRY_PASSWORD" | docker login \
+ --username "$REGISTRY_USERNAME" \
+ --password-stdin "$REGISTRY" &&
+ docker compose pull &&
+ docker compose up -d &&
+ echo "=== Stack status ===" &&
+ docker compose ps
+ '
+
+ # ── Build failure notification ───────────────────────────────────────────────
+ notify-failure:
+ name: Notify — build failure
+ runs-on: ubuntu-latest
+ needs: [build-api, deploy]
+ # Only run when something failed; skip on success or cancellation
+ if: failure()
+ steps:
+ - name: Post commit comment on failure
+ env:
+ REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
+ OWNER: ${{ gitea.repository_owner }}
+ SHA: ${{ gitea.event.workflow_run.head_sha || gitea.sha }}
+ RUN_URL: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}
+ run: |
+ BODY="❌ **Docker build failed** for \`${SHA:0:7}\`\n\nSee the failed run: ${RUN_URL}"
+ curl -sf -X POST \
+ "https://git.amlab.dev/api/v1/repos/${OWNER}/calorie-counter/commits/${SHA}/comments" \
+ -H "Authorization: token ${REGISTRY_PASSWORD}" \
+ -H "Content-Type: application/json" \
+ -d "{\"body\": \"${BODY}\"}" \
+ && echo "Failure comment posted" \
+ || echo "Warning: could not post comment (non-fatal)"
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..fb19278
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,51 @@
+# Generated by GitHub Copilot
+#
+# Build stage — Java 21 LTS + Maven (used only for local dev via --target local).
+# The CI workflow builds the JAR natively on the Pi runner before calling docker build
+# to avoid QEMU's unreliable TLS stack causing Maven Central downloads to time out.
+FROM eclipse-temurin:21-jdk-alpine AS build
+WORKDIR /workspace
+
+# Download Maven via curl — avoids apk's OpenJDK pulling in a conflicting JDK version.
+ENV MAVEN_VERSION=3.9.9
+RUN apk add --no-cache curl && \
+ curl -fsSL https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz \
+ | tar -xz -C /opt && \
+ ln -s /opt/apache-maven-${MAVEN_VERSION} /opt/maven
+ENV PATH="/opt/maven/bin:${PATH}"
+
+COPY pom.xml .
+# Pre-fetch deps as a cached layer; rebuilds on source-only changes skip this step.
+RUN mvn -q -ntp dependency:go-offline || true
+
+COPY src src
+RUN mvn -q -ntp clean package -DskipTests
+
+# ── Runtime base — shared between local and ci-image targets ──────────────────
+FROM eclipse-temurin:21-jre-alpine AS runtime-base
+WORKDIR /app
+
+# Non-root user — principle of least privilege (REQ-SEC-001)
+RUN addgroup -S caloriecounter && adduser -S caloriecounter -G caloriecounter
+USER caloriecounter
+
+EXPOSE 8080
+
+# Container-aware heap sizing: use 75% of cgroup memory limit
+ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75"
+ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
+
+# ── local target ──────────────────────────────────────────────────────────────
+# Copies JAR from the Maven build stage above.
+# Usage: docker build --target local -t calorie-counter-api .
+FROM runtime-base AS local
+COPY --from=build /workspace/target/*.jar app.jar
+
+# ── ci-image target (DEFAULT) ─────────────────────────────────────────────────
+# Copies a pre-built JAR from the Docker build context.
+# The CI workflow builds the JAR natively (outside QEMU) before docker build,
+# avoiding 20-min Maven downloads through QEMU's unreliable TLS stack.
+# Usage: docker build --build-arg JAR_FILE=target/calorie-counter-backend-1.0.0-SNAPSHOT.jar .
+FROM runtime-base AS ci-image
+ARG JAR_FILE=target/*.jar
+COPY ${JAR_FILE} app.jar
diff --git a/backend/pom.xml b/backend/pom.xml
index ec58744..9cecc8e 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -86,6 +86,12 @@
spring-boot-starter-webflux
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
org.springframework.boot
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index 89dcf3e..f08737c 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -36,6 +36,18 @@ openai:
openfoodfacts:
base-url: https://world.openfoodfacts.org
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health
+ endpoint:
+ health:
+ probes:
+ # Enable liveness + readiness probes — used by Docker HEALTHCHECK
+ enabled: true
+ show-details: never
+
logging:
level:
root: WARN
diff --git a/mobile/src/services/api.ts b/mobile/src/services/api.ts
index ce021e8..f7b34da 100644
--- a/mobile/src/services/api.ts
+++ b/mobile/src/services/api.ts
@@ -2,7 +2,10 @@
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
-const BASE_URL = process.env.API_BASE_URL ?? 'http://localhost:8080';
+// Production: https://calories.amlab.dev (Nginx Proxy Manager → Pi:8085)
+// Local dev: http://10.18.1.135:8085 (Pi LAN, via WireGuard if off-network)
+// Override: set API_BASE_URL env var in your .env file
+const BASE_URL = process.env.API_BASE_URL ?? 'https://calories.amlab.dev';
const api = axios.create({
baseURL: BASE_URL,