feat(deploy): build + serve worker and admin as static nginx images
All checks were successful
Build and Push Docker Image / build (push) Successful in 53s

- per-app Dockerfiles (vite build → nginx) + SPA nginx.conf
- Gitea workflow pushes 3 images; frontends bake VITE_API_URL
- docker-compose.prod.yml (registry images, solelog_network) + .env.prod.example
- docker-compose.yml runs the full stack locally; add .dockerignore
This commit is contained in:
Bas van Rossem
2026-06-17 21:11:32 +02:00
parent 1765f4036c
commit a7c8925b3c
11 changed files with 327 additions and 3 deletions

36
.dockerignore Normal file
View File

@@ -0,0 +1,36 @@
# Keep Docker build contexts small and reproducible.
# (CI checks out only git-tracked files, but local `docker compose build` would
# otherwise ship node_modules/dist — hundreds of MB — into the context.)
# dependencies
node_modules
**/node_modules
.pnp.*
# build output / incremental caches
**/dist
out
**/*.tsbuildinfo
**/coverage
# vcs / ci
.git
.gitignore
.gitea
# env & secrets (API URL is passed as a build-arg, never copied in)
**/.env
**/.env.*
!**/.env.example
# local API data (SQLite)
apps/api/data
apps/api/.tmp
# editor / os cruft
.idea
.DS_Store
# test artefacts
playwright-report
test-results

12
.env.prod.example Normal file
View File

@@ -0,0 +1,12 @@
# Production environment for docker-compose.prod.yml.
# Copy to `.env` on the server and fill in real values (`.env` is gitignored).
# Strong random secret — generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=replace-with-a-long-random-string
# Public URL of the API (what the browser and better-auth see)
BETTER_AUTH_URL=https://api.solelog.vrossem.net
# Browser origins allowed for CORS + better-auth trusted origins (comma-separated).
# These are the two frontend hostnames.
CORS_ORIGINS=https://solelog.vrossem.net,https://admin.solelog.vrossem.net

View File

@@ -18,7 +18,7 @@ jobs:
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push - name: Build and push API
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
@@ -27,3 +27,27 @@ jobs:
tags: | tags: |
gitea.vrossem.net/bas/solelog:latest gitea.vrossem.net/bas/solelog:latest
gitea.vrossem.net/bas/solelog:${{ gitea.sha }} gitea.vrossem.net/bas/solelog:${{ gitea.sha }}
- name: Build and push Worker
uses: docker/build-push-action@v5
with:
context: .
file: apps/worker/Dockerfile
push: true
build-args: |
VITE_API_URL=https://api.solelog.vrossem.net
tags: |
gitea.vrossem.net/bas/solelog-worker:latest
gitea.vrossem.net/bas/solelog-worker:${{ gitea.sha }}
- name: Build and push Admin
uses: docker/build-push-action@v5
with:
context: .
file: apps/admin/Dockerfile
push: true
build-args: |
VITE_API_URL=https://api.solelog.vrossem.net
tags: |
gitea.vrossem.net/bas/solelog-admin:latest
gitea.vrossem.net/bas/solelog-admin:${{ gitea.sha }}

28
apps/admin/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# ---- build stage: produce the static Vite bundle ----
FROM node:22-alpine AS build
RUN corepack enable
WORKDIR /repo
# Workspace manifests first, so `yarn install` layers cache across source changes
COPY package.json yarn.lock .yarnrc.yml ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY apps/admin/package.json ./apps/admin/package.json
RUN yarn workspaces focus @solelog/admin
# Sources (@solelog/shared is consumed as raw TS, so it just needs to be present)
COPY packages/shared/ ./packages/shared/
COPY apps/admin/ ./apps/admin/
# The API base URL is baked into the bundle at build time (Vite inlines import.meta.env.*).
# Defaults to localhost for local builds; the Gitea workflow overrides it for production.
ARG VITE_API_URL=http://localhost:3000
ENV VITE_API_URL=$VITE_API_URL
# `vite build` only (no `tsc -b`): the image ships the bundle; type/test checks
# are a separate CI concern and shouldn't gate the production image.
RUN yarn workspace @solelog/admin exec vite build
# ---- runtime stage: serve the static files with nginx ----
FROM nginx:alpine AS runtime
COPY apps/admin/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /repo/apps/admin/dist /usr/share/nginx/html
EXPOSE 80

23
apps/admin/nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
# Hashed build assets are immutable — cache them hard.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA fallback: any unknown path serves index.html so React Router can
# handle it client-side (deep links and refreshes work). Never cache the shell.
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}

View File

@@ -17,9 +17,9 @@ vi.mock('../api/me', () => ({
const mockedSignIn = vi.mocked(apiSignIn); const mockedSignIn = vi.mocked(apiSignIn);
const mockedFetchMe = vi.mocked(fetchMe); const mockedFetchMe = vi.mocked(fetchMe);
// A tiny harness that exposes the auth context's state + signIn. // A tiny harness that exposes the auth context's state + signIn + signOut.
function Harness() { function Harness() {
const { isAuthed, signIn } = useAuth(); const { isAuthed, signIn, signOut } = useAuth();
return ( return (
<div> <div>
<span data-testid="authed">{String(isAuthed)}</span> <span data-testid="authed">{String(isAuthed)}</span>
@@ -34,6 +34,9 @@ function Harness() {
> >
go go
</button> </button>
<button type="button" onClick={() => signOut()}>
uit
</button>
<span id="err" /> <span id="err" />
</div> </div>
); );

View File

@@ -72,4 +72,71 @@ describe('Live', () => {
expect(await screen.findByText('Niemand is nu aan het werk.')).toBeInTheDocument(); expect(await screen.findByText('Niemand is nu aan het werk.')).toBeInTheDocument();
expect(screen.getByText('Actief nu (0)')).toBeInTheDocument(); expect(screen.getByText('Actief nu (0)')).toBeInTheDocument();
}); });
it('shows a Gepauzeerd badge with a frozen worked timer when paused_at is set', async () => {
vi.useFakeTimers();
const start = Date.now() - 100_000;
const pausedAt = Date.now() - 40_000; // paused 40s ago
mockApiFetch.mockResolvedValue([
makeSession({
id: 1,
user_name: 'Jan',
start_time: new Date(start).toISOString(),
paused_at: new Date(pausedAt).toISOString(),
paused_seconds: 10,
}),
]);
renderLive();
// The fake clock needs to advance for react-query's async resolution.
await vi.advanceTimersByTimeAsync(0);
expect(screen.getByText('Gepauzeerd')).toBeInTheDocument();
// worked = (paused_at - start)/1000 - paused_seconds = 60 - 10 = 50s = 00:00:50
const frozen = screen.getByText('00:00:50');
expect(frozen).toBeInTheDocument();
// Advance the 1s tick: the worked timer must stay frozen (does not count up).
await vi.advanceTimersByTimeAsync(3000);
expect(screen.getByText('00:00:50')).toBeInTheDocument();
});
it('shows the paused total when paused_seconds > 0', async () => {
mockApiFetch.mockResolvedValue([
makeSession({
id: 1,
user_name: 'Jan',
paused_seconds: 125,
paused_at: new Date().toISOString(),
}),
]);
renderLive();
// 125s = 00:02:05
expect(await screen.findByText('Pauze 00:02:05')).toBeInTheDocument();
});
it('keeps the timer counting (no Gepauzeerd badge) when not paused', async () => {
vi.useFakeTimers();
mockApiFetch.mockResolvedValue([
makeSession({
id: 1,
user_name: 'Jan',
start_time: new Date(Date.now() - 10_000).toISOString(),
paused_at: null,
paused_seconds: 0,
}),
]);
renderLive();
await vi.advanceTimersByTimeAsync(0);
expect(screen.queryByText('Gepauzeerd')).not.toBeInTheDocument();
expect(screen.getByText('00:00:10')).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(2000);
expect(screen.getByText('00:00:12')).toBeInTheDocument();
});
}); });

28
apps/worker/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# ---- build stage: produce the static Vite bundle ----
FROM node:22-alpine AS build
RUN corepack enable
WORKDIR /repo
# Workspace manifests first, so `yarn install` layers cache across source changes
COPY package.json yarn.lock .yarnrc.yml ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY apps/worker/package.json ./apps/worker/package.json
RUN yarn workspaces focus @solelog/worker
# Sources (@solelog/shared is consumed as raw TS, so it just needs to be present)
COPY packages/shared/ ./packages/shared/
COPY apps/worker/ ./apps/worker/
# The API base URL is baked into the bundle at build time (Vite inlines import.meta.env.*).
# Defaults to localhost for local builds; the Gitea workflow overrides it for production.
ARG VITE_API_URL=http://localhost:3000
ENV VITE_API_URL=$VITE_API_URL
# `vite build` only (no `tsc -b`): the image ships the bundle; type/test checks
# are a separate CI concern and shouldn't gate the production image.
RUN yarn workspace @solelog/worker exec vite build
# ---- runtime stage: serve the static files with nginx ----
FROM nginx:alpine AS runtime
COPY apps/worker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /repo/apps/worker/dist /usr/share/nginx/html
EXPOSE 80

23
apps/worker/nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
# Hashed build assets are immutable — cache them hard.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA fallback: any unknown path serves index.html so React Router can
# handle it client-side (deep links and refreshes work). Never cache the shell.
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}

52
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,52 @@
# Production deployment — runs on the server behind the Caddy reverse proxy.
# Images are built and pushed by the Gitea workflow (.gitea/workflows/docker.yml).
#
# One-time server setup:
# docker network create solelog_network
# # attach your (containerised) Caddy to that network, e.g. add it to Caddy's
# # compose as an external network, or: docker network connect solelog_network <caddy-container>
# cp .env.prod.example .env # then fill in real values
#
# Deploy / update:
# docker compose -f docker-compose.prod.yml pull
# docker compose -f docker-compose.prod.yml up -d
#
# Caddyfile (on your edge Caddy — it resolves these names over solelog_network):
# api.solelog.vrossem.net { reverse_proxy solelog-api:3000 }
# solelog.vrossem.net { reverse_proxy solelog-worker:80 }
# admin.solelog.vrossem.net { reverse_proxy solelog-admin:80 }
services:
api:
image: gitea.vrossem.net/bas/solelog:latest
container_name: solelog-api
env_file: .env # BETTER_AUTH_SECRET, BETTER_AUTH_URL, CORS_ORIGINS
environment:
DATABASE_URL: file:/data/app.db
PORT: '3000'
volumes:
- solelog_db:/data
networks:
- solelog_network
restart: unless-stopped
worker:
image: gitea.vrossem.net/bas/solelog-worker:latest
container_name: solelog-worker
networks:
- solelog_network
restart: unless-stopped
admin:
image: gitea.vrossem.net/bas/solelog-admin:latest
container_name: solelog-admin
networks:
- solelog_network
restart: unless-stopped
networks:
solelog_network:
external: true
volumes:
solelog_db:

View File

@@ -1,3 +1,8 @@
# Local full-stack build. Runs the API + both frontends on your machine:
# docker compose up --build
# API on :3000, worker on :5173, admin on :5174 (matching the Vite dev ports).
# For the server deployment see docker-compose.prod.yml.
services: services:
api: api:
build: build:
@@ -9,9 +14,32 @@ services:
DATABASE_URL: file:/data/app.db DATABASE_URL: file:/data/app.db
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-change-me-to-a-long-random-string} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-change-me-to-a-long-random-string}
BETTER_AUTH_URL: http://localhost:3000 BETTER_AUTH_URL: http://localhost:3000
CORS_ORIGINS: http://localhost:5173,http://localhost:5174
PORT: '3000' PORT: '3000'
volumes: volumes:
- solelog_db:/data - solelog_db:/data
worker:
build:
context: .
dockerfile: apps/worker/Dockerfile
args:
VITE_API_URL: http://localhost:3000
ports:
- '5173:80'
depends_on:
- api
admin:
build:
context: .
dockerfile: apps/admin/Dockerfile
args:
VITE_API_URL: http://localhost:3000
ports:
- '5174:80'
depends_on:
- api
volumes: volumes:
solelog_db: solelog_db: