Skip to content
Merged
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
42 changes: 42 additions & 0 deletions .claude/agents/docker-builder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
name: docker-builder
description: Authors and reviews Dockerfiles, .dockerignore, and container build steps for this repo against a strict build/optimization/security/maintainability rule set. Use when adding or changing containerization, the Docker CI job, or when reviewing a Dockerfile before merge.
tools: Read, Grep, Glob, Edit, Write, Bash
model: sonnet
---

You containerize a **client-side Vite SPA** (React 19, TypeScript, pnpm). The build compiles to static assets in `dist/`; the runtime just serves them (non-root nginx). **No Node.js, pnpm, or build tooling belongs in the runtime image**, and no auth/secret logic runs in the container — the app only calls the backend.

Read `CLAUDE.md`, `package.json` (`packageManager` pins the pnpm version), and the existing `Dockerfile` / `docker/nginx.conf` / `.dockerignore` first, and match their patterns. When you edit a Dockerfile, keep it passing `hadolint` and the repo's Docker CI job.

Apply these rules. When reviewing, cite the offending line and give the minimal fix; when authoring, satisfy every applicable rule.

### Build
1. **Minimal base images** — alpine/slim variants (`node:*-alpine` build stage, `nginx-unprivileged:*-alpine` runtime).
2. **Multi-stage build** — a `build` stage (deps + `pnpm build`) and a lean `runtime` stage that only `COPY --from=build` the `dist/` output.
3. **Derive the version from the project** — take it from `package.json` (`APP_VERSION` build arg) rather than hardcoding, and surface it via an OCI label.

### Optimization
1. **Layer caching** — copy `package.json` + `pnpm-lock.yaml` and install *before* copying source, so source edits don't invalidate the deps layer. Put rarely-changing steps first; keep layer count reasonable. Use a BuildKit cache mount for the pnpm store.
2. **Combine RUN commands** — chain related shell steps with `&&`, clean caches in the same layer, and don't leave separate throwaway layers.
3. **Explicit COPY** — copy named paths (configs, `src`, `public`); never `COPY . .`. Keep `.dockerignore` tight as defense-in-depth.
4. **Production deps only in the final image** — the runtime image carries zero Node deps (static nginx). The build stage may use dev deps for `tsc`/`vite`; they must not leak into runtime.

### Security
1. **Non-root user** — run as a non-root UID (the `nginx-unprivileged` base already does; if you switch base, add a `USER`).
2. **Pin image versions** — pin base images to a specific tag, ideally a digest (`image@sha256:…`); never `latest`.
3. **Official images** — only official/trusted publishers (Docker Official / verified org images).
4. **No secrets in the image** — no tokens/keys in `ENV`, `ARG` defaults, or copied files. Only public `VITE_*` values may be build args (they ship in the client bundle); everything sensitive stays backend-side.
5. **No sudo** — never install or invoke `sudo`.
6. **Minimal packages** — install nothing beyond what's required; no `apk add`/`apt-get install` of convenience tools.
7. **COPY over ADD** — use `COPY`; avoid `ADD` (no implicit URL fetches or auto-extraction).
8. **No debugging tools** — no curl/wget/vim/netcat/shells-as-tools baked into the runtime image.

### Maintainability
1. **Sort arguments** — keep `ARG`/`ENV`/multi-value lists alphabetically ordered where practical.
2. **Use WORKDIR** — set `WORKDIR` instead of `cd` in `RUN`.
3. **Exec form for CMD/ENTRYPOINT** — `CMD ["nginx", "-g", "daemon off;"]`, not shell form, so the process is PID 1 and gets signals.
4. **Comment non-obvious decisions** — explain *why* (cache mounts, unprivileged port 8080, SPA fallback), not the obvious.
5. **Add OCI labels** — `org.opencontainers.image.*` (title, description, version, source, licenses).

Do not add auth, session, or secret-handling logic to any container artifact. If a change would require a runtime backend or secrets in the image, stop and flag it rather than baking them in.
40 changes: 40 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Keep the build context tiny and deterministic. The Dockerfile uses explicit
# COPY lines, so this is defense-in-depth against leaking local/dev artifacts.

# Dependencies & build output (reinstalled/rebuilt inside the image)
node_modules
dist
dist-ssr
.tanstack
.dependencygraph

# VCS & CI
.git
.github

# Agent / tooling config (not needed to build the app)
.claude
.agents
.mcp.json
skills-lock.json

# Env & secrets — never ship these into the image
.env
.env.*
*.local

# Editor / OS noise
.vscode
.idea
.DS_Store
*.log

# Docs & non-build project files
docs
*.md
LICENSE
netlify.toml
cz.yaml

# Foreign lockfile — this project uses pnpm-lock.yaml
package-lock.json
104 changes: 104 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

# Least-privilege by default; jobs opt into more only if they need it.
permissions:
contents: read

# Only the latest run per ref matters — cancel superseded runs.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
# ---------------------------------------------------------------------------
# Verify the app with pnpm: lint, format, typecheck + build.
# ---------------------------------------------------------------------------
verify:
name: Lint, format & build (pnpm)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

# Installs the exact pnpm from package.json's "packageManager" field.
- name: Install pnpm
uses: pnpm/action-setup@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Lint
run: pnpm lint

- name: Format check
run: pnpm format:check

- name: Build
run: pnpm build
env:
# ENV RULE: only public VITE_* values here — Vite inlines them into the
# client bundle. Never reference secrets; use repo/environment *variables*
# (vars.*), not secrets.*, so nothing sensitive can leak into shipped JS.
VITE_AVATAR: ${{ vars.VITE_AVATAR }}
VITE_API_URL: ${{ vars.VITE_API_URL }} # placeholder until the backend lands

# ---------------------------------------------------------------------------
# Enforce the Docker rules: lint the Dockerfile, build the image, scan it.
# ---------------------------------------------------------------------------
docker:
name: Docker lint, build & scan
runs-on: ubuntu-latest
needs: verify
steps:
- name: Checkout
uses: actions/checkout@v4

# Static Dockerfile analysis (best-practice + security lint).
- name: Lint Dockerfile (hadolint)
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile

# Derive the image version from the project (package.json).
- name: Resolve app version
id: meta
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"

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

- name: Build image
uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: faishon-e-com:ci
build-args: |
APP_VERSION=${{ steps.meta.outputs.version }}
VITE_AVATAR=${{ vars.VITE_AVATAR }}
VITE_API_URL=${{ vars.VITE_API_URL }}
cache-from: type=gha
cache-to: type=gha,mode=max

# Fail the build on known HIGH/CRITICAL vulns and any leaked secrets.
- name: Scan image (Trivy)
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: faishon-e-com:ci
scanners: vuln,secret
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: '1'
80 changes: 80 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# syntax=docker/dockerfile:1

# =============================================================================
# Fashion e-commerce SPA — multi-stage image.
# Stage 1 (build) compiles the Vite SPA with pnpm.
# Stage 2 (runtime) serves the static bundle from a minimal, non-root nginx.
# No Node.js, pnpm, or build tooling survives into the runtime image.
# =============================================================================

# ---- Build arguments (sorted; pinned versions) -----------------------------
# NOTE: pin to digests (image@sha256:...) for fully reproducible builds.
ARG NGINX_VERSION=1.27-alpine
ARG NODE_VERSION=22-alpine
ARG PNPM_VERSION=9.12.1

# -----------------------------------------------------------------------------
# Stage 1: build the static assets
# -----------------------------------------------------------------------------
FROM node:${NODE_VERSION} AS build

# Activate the exact pnpm from package.json's "packageManager" via corepack.
ARG PNPM_VERSION
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate

WORKDIR /app

# Install deps first, keyed only on the manifest + lockfile, so this layer is
# reused until dependencies actually change (source edits must not bust it).
# The BuildKit cache mount keeps the pnpm store warm across builds.
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile

# Explicit copies only — never `COPY . .`. Add build inputs here if new
# top-level config the build needs is introduced.
COPY tsconfig.json tsconfig.app.json tsconfig.node.json vite.config.ts postcss.config.js index.html ./
COPY public ./public
COPY src ./src

# Vite inlines VITE_*-prefixed vars into the client bundle at build time.
# ONLY public values may be passed here — a secret build-arg would be baked
# into shipped JS. Real auth/tokens are handled by the backend at runtime.
# VITE_AVATAR — currently consumed by the app (public asset URL).
# VITE_API_URL — placeholder for the planned backend base URL (see roadmap).
ARG VITE_AVATAR=""
ARG VITE_API_URL=""
ENV VITE_API_URL=${VITE_API_URL} \
VITE_AVATAR=${VITE_AVATAR}

RUN pnpm build

# -----------------------------------------------------------------------------
# Stage 2: runtime — static files on non-root nginx
# -----------------------------------------------------------------------------
# nginxinc/nginx-unprivileged runs as UID 101 (non-root) and listens on 8080.
FROM nginxinc/nginx-unprivileged:${NGINX_VERSION} AS runtime

# Version is derived from the project (package.json) and passed by build tooling.
ARG APP_VERSION=0.0.0

# OCI image metadata for provenance and tooling.
LABEL org.opencontainers.image.title="faishon-e-com" \
org.opencontainers.image.description="Fashion e-commerce SPA (storefront + admin)" \
org.opencontainers.image.version="${APP_VERSION}" \
org.opencontainers.image.source="https://github.com/fzsf163/faishon-e-com-tanstack" \
org.opencontainers.image.licenses="MIT"

# SPA config with history fallback (mirrors the Netlify redirect).
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf

# Static assets only — no shell utilities, package managers, or debug tooling
# are installed into this layer.
COPY --from=build /app/dist /usr/share/nginx/html

# Already runs as the non-root UID baked into the base image (USER 101).
EXPOSE 8080

# Exec form so nginx is PID 1 and receives signals directly.
CMD ["nginx", "-g", "daemon off;"]
20 changes: 20 additions & 0 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
server {
# nginx-unprivileged listens on 8080 as a non-root user.
listen 8080;
server_name _;

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

# Vite emits content-hashed filenames under /assets — safe to cache forever.
location /assets/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}

# SPA history fallback — mirrors the Netlify `/* -> /index.html` redirect.
location / {
try_files $uri $uri/ /index.html;
}
}
Loading