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
58 changes: 58 additions & 0 deletions .github/workflows/frontend-container.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Frontend container

on:
push:
paths:
- 'frontend/**'
- '.github/workflows/frontend-container.yml'
pull_request:
paths:
- 'frontend/**'
- '.github/workflows/frontend-container.yml'

permissions:
contents: read

concurrency:
group: frontend-container-${{ github.ref }}
cancel-in-progress: true

jobs:
container:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Build frontend image
run: docker build --pull -t payd-frontend:ci -f frontend/Dockerfile frontend
- name: Check image size and nginx configuration
run: |
bytes=$(docker image inspect payd-frontend:ci --format '{{.Size}}')
echo "Frontend image size: ${bytes} bytes"
test "$bytes" -lt 50000000
docker run --rm payd-frontend:ci nginx -t
- name: Check static serving and SPA fallback
shell: bash
run: |
set -euo pipefail
container=$(docker run -d -p 127.0.0.1:8080:80 payd-frontend:ci)
trap 'docker rm -f "$container" >/dev/null' EXIT
for attempt in $(seq 1 30); do
if curl -fsS http://127.0.0.1:8080/ -o /tmp/index.html; then break; fi
sleep 1
done
curl -fsS -D /tmp/index.headers http://127.0.0.1:8080/index.html -o /tmp/index.html
grep -qi '^Cache-Control: no-store' /tmp/index.headers
curl -fsS -D /tmp/route.headers http://127.0.0.1:8080/dashboard -o /tmp/route.html
cmp /tmp/index.html /tmp/route.html
grep -qi '^Cache-Control: no-store' /tmp/route.headers
asset=$(docker exec "$container" find /usr/share/nginx/html/assets -type f -name '*.js' | head -n 1)
test -n "$asset"
asset=${asset#/usr/share/nginx/html}
curl -fsS -D /tmp/asset.headers "http://127.0.0.1:8080${asset}" -o /dev/null
grep -qi '^Cache-Control: public, max-age=31536000, immutable' /tmp/asset.headers
status=$(curl -sS -D /tmp/missing.headers -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/assets/missing-container-smoke.js)
test "$status" = 404
! grep -qi 'immutable' /tmp/missing.headers
10 changes: 10 additions & 0 deletions frontend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.git
**/node_modules
**/dist
**/.vite
**/coverage
playwright-report
test-results
*.log
.env
.env.*
40 changes: 40 additions & 0 deletions frontend/DOCKER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Frontend container

From the repository root:

```sh
docker build --pull -t payd-frontend:local -f frontend/Dockerfile frontend
docker run --rm -p 8080:80 payd-frontend:local
```

The Node 22 build stage runs the existing locked dependency install and
`npm run build`; only Vite's `dist` output enters the nginx Alpine slim runtime.
Port 80 matches the Kubernetes and Helm frontend service/probe configuration.
Build errors are not suppressed. No backend, database, or provider runs in this
image: route API requests to the backend through the existing ingress. Vite's
local development proxy is not part of a production static server.

Vite embeds public configuration at build time. Pass the required
`PUBLIC_STELLAR_NETWORK`, `PUBLIC_STELLAR_NETWORK_PASSPHRASE`,
`PUBLIC_STELLAR_RPC_URL`, and `PUBLIC_STELLAR_HORIZON_URL` using `--build-arg`.
Do not pass secrets as build arguments. Local `.env` files are excluded from the
build context; runtime `docker run -e` values do not rewrite the built application.

## Focused acceptance checks

```sh
# Issue #539 requires the uncompressed local image to be below 50 MB.
bytes=$(docker image inspect payd-frontend:local --format '{{.Size}}')
test "$bytes" -lt 50000000

docker run --rm payd-frontend:local nginx -t
curl -fsSI http://localhost:8080/index.html # Cache-Control: no-store
curl -fsSI http://localhost:8080/dashboard # SPA fallback; no-store
curl -sSI http://localhost:8080/assets/missing.js # 404; no immutable cache
# Request a real /assets/<hashed-name>.js from index.html:
# Cache-Control: public, max-age=31536000, immutable
```

`index.html` and SPA fallbacks are never stored. Vite's hashed `/assets/` files
are immutable for one year; other public files must revalidate. Check the image
size after building, since application assets and upstream image tags can grow.
27 changes: 27 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Build from the repository root:
# docker build -t payd-frontend -f frontend/Dockerfile frontend
FROM node:22-alpine AS build
WORKDIR /app

# The wallet dependency tree includes node-usb, which builds from source on musl.
# These tools and headers remain in the build stage, not the nginx image.
RUN apk add --no-cache python3 make g++ pkgconf eudev-dev linux-headers

# Copy the workspace sources before npm ci so local packages are available too.
COPY . .
RUN npm ci --legacy-peer-deps

# These are public browser configuration, never credentials. Vite substitutes
# them at build time; setting container environment variables cannot change dist.
ARG PUBLIC_STELLAR_NETWORK
ARG PUBLIC_STELLAR_NETWORK_PASSPHRASE
ARG PUBLIC_STELLAR_RPC_URL
ARG PUBLIC_STELLAR_HORIZON_URL
RUN npm run build

FROM nginx:stable-alpine-slim AS runtime
RUN rm -rf /usr/share/nginx/html/*
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/ /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
26 changes: 26 additions & 0 deletions frontend/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;

# Vite fingerprints these files. Missing assets must not become HTML or
# receive a long-lived cache header.
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}

# The entry point must always reflect the currently deployed asset hashes.
# Internal SPA redirects also pass through this exact location.
location = /index.html {
add_header Cache-Control "no-store" always;
}

# Public files outside /assets are not necessarily content-addressed.
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache" always;
}
}