Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 46 additions & 9 deletions .github/workflows/auto-docker-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set lowercase repository name
id: repo
run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

Expand All @@ -58,8 +62,8 @@ jobs:
uses: docker/metadata-action@v5
with:
images: |
codewithcj/sparkyfitness
ghcr.io/${{ github.repository }}-frontend
${{ secrets.DOCKER_USERNAME || 'codewithcj' }}/sparkyfitness
ghcr.io/${{ steps.repo.outputs.name }}-frontend
tags: |
type=raw,value=${{ steps.get_latest_release.outputs.tag }}
type=raw,value=latest
Expand All @@ -80,17 +84,50 @@ jobs:
- name: Generate artifact attestation Frontend
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/${{ github.repository }}-frontend
subject-name: ghcr.io/${{ steps.repo.outputs.name }}-frontend
subject-digest: ${{ steps.push-frontend.outputs.digest }}
push-to-registry: true

- name: Metadata frontend non-root
id: meta-frontend-nonroot
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME || 'codewithcj' }}/sparkyfitness_frontend_nonroot
ghcr.io/${{ steps.repo.outputs.name }}-frontend-nonroot
tags: |
type=raw,value=${{ steps.get_latest_release.outputs.tag }}
type=raw,value=latest

- name: Build and push frontend non-root
id: push-frontend-nonroot
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.frontend.nonroot
build-args: |
FRONTEND_BASE_IMAGE=ghcr.io/${{ steps.repo.outputs.name }}-frontend:${{ steps.meta-frontend.outputs.version }}
push: true
tags: ${{ steps.meta-frontend-nonroot.outputs.tags }}
labels: ${{ steps.meta-frontend-nonroot.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Generate artifact attestation Frontend non-root
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/${{ steps.repo.outputs.name }}-frontend-nonroot
subject-digest: ${{ steps.push-frontend-nonroot.outputs.digest }}
push-to-registry: true

- name: Metadata server
id: meta-server
uses: docker/metadata-action@v5
with:
images: |
codewithcj/sparkyfitness_server
ghcr.io/${{ github.repository }}-server
${{ secrets.DOCKER_USERNAME || 'codewithcj' }}/sparkyfitness_server
ghcr.io/${{ steps.repo.outputs.name }}-server
tags: |
type=raw,value=${{ steps.get_latest_release.outputs.tag }}
type=raw,value=latest
Expand All @@ -111,7 +148,7 @@ jobs:
- name: Generate artifact attestation server
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/${{ github.repository }}-server
subject-name: ghcr.io/${{ steps.repo.outputs.name }}-server
subject-digest: ${{ steps.push-server.outputs.digest }}
push-to-registry: true

Expand All @@ -120,8 +157,8 @@ jobs:
uses: docker/metadata-action@v5
with:
images: |
codewithcj/sparkyfitness_garmin
ghcr.io/${{ github.repository }}-garmin
${{ secrets.DOCKER_USERNAME || 'codewithcj' }}/sparkyfitness_garmin
ghcr.io/${{ steps.repo.outputs.name }}-garmin
tags: |
type=raw,value=${{ steps.get_latest_release.outputs.tag }}
type=raw,value=latest
Expand All @@ -142,6 +179,6 @@ jobs:
- name: Generate artifact attestation Garmin Microservice
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/${{ github.repository }}-garmin
subject-name: ghcr.io/${{ steps.repo.outputs.name }}-garmin
subject-digest: ${{ steps.push-garmin.outputs.digest }}
push-to-registry: true
3 changes: 3 additions & 0 deletions .github/workflows/helm-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ jobs:
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin

- name: Fetch chart dependencies
run: helm dependency update helm/chart

- name: Package Helm chart
run: |
helm package helm/chart \
Expand Down
34 changes: 33 additions & 1 deletion .github/workflows/manual-docker-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,38 @@ jobs:
subject-digest: ${{ steps.push-frontend.outputs.digest }}
push-to-registry: true

- name: Metadata frontend non-root
id: meta-frontend-nonroot
uses: docker/metadata-action@v5
with:
images: |
codewithcj/sparkyfitness_frontend_nonroot
ghcr.io/${{ github.repository }}-frontend-nonroot
tags: |
type=raw,value=${{ github.event.inputs.tag }}

- name: Build and push frontend non-root
id: push-frontend-nonroot
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.frontend.nonroot
build-args: |
FRONTEND_BASE_IMAGE=ghcr.io/${{ github.repository }}-frontend:${{ steps.meta-frontend.outputs.version }}
push: true
tags: ${{ steps.meta-frontend-nonroot.outputs.tags }}
labels: ${{ steps.meta-frontend-nonroot.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Generate artifact attestation Frontend non-root
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/${{ github.repository }}-frontend-nonroot
subject-digest: ${{ steps.push-frontend-nonroot.outputs.digest }}
push-to-registry: true

- name: Metadata server
id: meta-server
uses: docker/metadata-action@v5
Expand Down Expand Up @@ -126,4 +158,4 @@ jobs:
with:
subject-name: ghcr.io/${{ github.repository }}-garmin
subject-digest: ${{ steps.push-garmin.outputs.digest }}
push-to-registry: true
push-to-registry: true
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ lerna-debug.log*

# Generic
postgresql
!helm/chart/charts/postgresql/
!helm/chart/charts/postgresql/**
node_modules
dist
dist-ssr
Expand Down
6 changes: 5 additions & 1 deletion SparkyFitnessServer/utils/dbMigrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ async function applyMigrations() {
);
if (roleExistsResult.rowCount === 0) {
log('info', `Creating role: ${appUserQuoted}`);
// Escape single quotes by doubling them (standard PostgreSQL string literal
// escaping). DDL statements do not support parameterized placeholders, so
// this is the correct way to safely interpolate the password.
const escapedPassword = (appPassword ?? '').replace(/'/g, "''");
await client.query(
`CREATE ROLE ${appUserQuoted} WITH LOGIN PASSWORD '${appPassword}'`
`CREATE ROLE ${appUserQuoted} WITH LOGIN PASSWORD '${escapedPassword}'`
);
log('info', `Successfully created role: ${appUserQuoted}`);
} else {
Expand Down
3 changes: 2 additions & 1 deletion docker/Docker_deploy_manual_command.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
docker buildx build --platform linux/amd64,linux/arm64 -t codewithcj/sparkyfitness:v0.15.0 -f docker/Dockerfile.frontend . --push
docker buildx build --platform linux/amd64,linux/arm64 -t codewithcj/sparkyfitness_frontend_nonroot:v0.15.0 --build-arg FRONTEND_BASE_IMAGE=codewithcj/sparkyfitness:v0.15.0 -f docker/Dockerfile.frontend.nonroot . --push
docker buildx build --platform linux/amd64,linux/arm64 -t codewithcj/sparkyfitness_server:v0.15.0 -f docker/Dockerfile.backend SparkyFitnessServer --push




docker-compose -f docker-compose.db_dev.yml up -d
docker-compose -f docker-compose.db_dev.yml up -d
24 changes: 24 additions & 0 deletions docker/Dockerfile.frontend.nonroot
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
ARG FRONTEND_BASE_IMAGE=codewithcj/sparkyfitness:latest
FROM ${FRONTEND_BASE_IMAGE}

# Reconfigure nginx so the frontend can run as an unprivileged user in
# Kubernetes while serving only static assets and SPA routes. API and upload
# routing is handled by Ingress/HTTPRoute rather than frontend proxying.
COPY docker/nginx.nonroot.conf.template /etc/nginx/templates/default.conf.template
COPY docker/docker-entrypoint.nonroot.sh /docker-entrypoint.sh

RUN chmod +x /docker-entrypoint.sh \
&& mkdir -p /var/run/nginx /var/cache/nginx /etc/nginx/conf.d \
&& chown -R 101:101 /var/run/nginx \
/var/cache/nginx \
/var/log/nginx \
/etc/nginx/conf.d \
/usr/share/nginx/html

USER 101:101

EXPOSE 8080

HEALTHCHECK --interval=5s --timeout=10s --retries=5 CMD wget -qO /dev/null http://127.0.0.1:8080/ || exit 1

CMD ["/docker-entrypoint.sh"]
17 changes: 17 additions & 0 deletions docker/docker-entrypoint.nonroot.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu

echo "Starting SparkyFitness Frontend (non-root)"

mkdir -p /var/run/nginx \
/var/cache/nginx/client-body \
/var/cache/nginx/proxy \
/var/cache/nginx/fastcgi \
/var/cache/nginx/uwsgi \
/var/cache/nginx/scgi \
/etc/nginx/conf.d

cp /etc/nginx/templates/default.conf.template /etc/nginx/conf.d/default.conf
Comment on lines +6 to +14
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This script attempts to mkdir and cp files at runtime. If the container is run with readOnlyRootFilesystem: true (as set in values.yaml), these operations will fail unless the target paths (like /etc/nginx/conf.d and /var/cache/nginx) are backed by emptyDir volumes. The current Helm templates do not appear to include these volume mounts for the frontend component, which will prevent the non-root image from starting in restricted environments.

Copy link
Copy Markdown
Contributor Author

@ikogan ikogan Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fronted container does indeed use emptyDir volumes for these:

          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: nginx-cache
              mountPath: /var/cache/nginx
            - name: nginx-run
              mountPath: /var/run
            - name: nginx-conf
              mountPath: /etc/nginx/conf.d
      volumes:
        - name: tmp
          emptyDir: {}
        - name: nginx-cache
          emptyDir: {}
        - name: nginx-run
          emptyDir: {}
        - name: nginx-conf
          emptyDir: {}

Moreover, it is run with readOnlyRootFilesystem: true by default.


nginx -t
exec nginx -g "daemon off;"
41 changes: 41 additions & 0 deletions docker/nginx.nonroot.conf.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
server {
listen 8080;
server_name localhost;

add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https:; font-src 'self' https:;" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

root /usr/share/nginx/html;
index index.html index.htm;

gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
gzip_disable "MSIE [1-6]\.";

location /assets/ {
expires 1y;
add_header Cache-Control "public, no-transform, immutable";
try_files $uri =404;
}

location ~* \.(?:png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 7d;
add_header Cache-Control "public, no-transform, immutable";
}

location / {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri $uri/ /index.html;
}

client_max_body_size 10m;

access_log /dev/stdout;
error_log /dev/stderr warn;
}
1 change: 1 addition & 0 deletions helm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
chart/charts
13 changes: 13 additions & 0 deletions helm/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## Unreleased

### Features

- **Dedicated non-root frontend image** — Helm now defaults to `codewithcj/sparkyfitness_frontend_nonroot`, which is layered from the canonical frontend image build and runs nginx as UID/GID `101` on port `8080`.
- **Bundled PostgreSQL dependency restored** — the chart now depends on `helmforge/postgresql`, a namespace-scoped PostgreSQL chart that uses the official `postgres` image and exposes scheduled backup support without requiring an operator.
- **PostgreSQL 18.3 defaults and backup modes** — bundled PostgreSQL now defaults to `postgres:18.3-trixie`, keeps the built-in S3 backup settings under `postgresql.backup`, and adds a PVC-backed retained backup job under `databaseBackup`.
- **Ingress-owned app routing** — the chart's Ingress and HTTPRoute now route `/api` and `/uploads` to the server service directly, so the frontend nginx only serves static assets and SPA routes.

### Chores

- **Frontend image publishing expanded** — Docker publish workflows now build and push the base frontend image first and the non-root frontend variant second to both Docker Hub and GHCR.

## 0.2.0

### Breaking Changes
Expand Down
Loading
Loading