This monorepo contains:
apps/web: Nuxt 3 SSR blog UIapps/api: Express API with file-backed storage (data/uploads)
Use the production Docker setup below to deploy quickly with Docker Hub images and Nginx.
- Use feature branches and conventional commits.
- Before commit/PR:
- Run tests and guards:
npm run test:all - Ensure hidden-layer content (docs/, internal/, glyphs/) is not staged.
- Run tests and guards:
- Open PRs using the provided template; include governance adherence and runtime ports.
- Merge the feature branch to
mainwhen all CI checks are green. - Append the latest changes to the
Changelogsection in thisREADME.md. - Create and push an annotated tag for the release:
VERSION=v0.2.3 git tag -a "$VERSION" -m "release: $VERSION" git push origin "$VERSION"
- Delete the merged branch locally and on origin:
BR=feat/v0.2.3-seo-security-editor git branch -d "$BR" || true git push origin --delete "$BR" || true
- Deploy production images (Docker Hub pull + compose up):
# Ensure .env.prod is present (see .env.prod.example) docker compose --env-file .env.prod -f docker-compose.prod.yml pull docker compose --env-file .env.prod -f docker-compose.prod.yml up -d - Post-merge smoke test: confirm
.github/workflows/post-merge-smoke.ymlpasses against the remote API URL (checks auth, basic endpoints, and og:image). - Governance upkeep: keep branch protection required checks in sync with current workflow job names to avoid stale/phantom failures.
Create a local git hook to block commits if checks fail:
mkdir -p .githooks
cat > .githooks/pre-commit <<'SH'
#!/usr/bin/env bash
set -euo pipefail
npm run test:all
SH
chmod +x .githooks/pre-commit
git config core.hooksPath .githooksThis repository avoids committing private governance/glyph content; CI enforces guards.
Deploy the latest release using prebuilt images from Docker Hub namespace ava2016.
- Create
.env.prodnext todocker-compose.prod.yml(use.env.prod.exampleas a base):
# API
JWT_SECRET=super_secret_value
CORS_ORIGIN=https://your.site
PUBLIC_BASE_URL=https://api.your.site
# Web (browser calls API here)
NUXT_PUBLIC_API_BASE=https://api.your.site- Pull and start services (from repo root):
docker compose --env-file .env.prod -f docker-compose.prod.yml pull
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml psNotes
- Web runs in the container on PORT=5000; expose via Nginx or port mapping (e.g., 5588:5000).
- API runs on 3000; expose via Nginx or mapping (e.g., 3388:3000).
- The production compose file runs both containers as non-root, drops Linux capabilities, and keeps the root filesystem read-only.
- Current web image includes the Nuxt vite-builder runtime fix and stable Nitro start.
Rollback
# If you tag images per release, set TAG (e.g., v0.2.4) in .env.prod or compose file
docker compose --env-file .env.prod -f docker-compose.prod.yml pull
docker compose --env-file .env.prod -f docker-compose.prod.yml up -dWe provide a production compose file: docker-compose.prod.yml.
Replace DOCKER_NS with your Docker Hub namespace.
# Web (Nuxt SSR)
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t DOCKER_NS/glyph-web:vX.Y.Z \
-t DOCKER_NS/glyph-web:latest \
-f apps/web/Dockerfile apps/web --push
# API (Express)
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t DOCKER_NS/glyph-api:vX.Y.Z \
-t DOCKER_NS/glyph-api:latest \
-f apps/api/Dockerfile apps/api --push- Ensure Docker + docker compose v2, and Nginx as reverse proxy.
- DNS:
- Web:
yourdomain.com-> server IP - API:
api.yourdomain.com-> server IP
- Web:
Create .env.prod next to docker-compose.prod.yml (see .env.prod.example):
DOCKER_NS=your-dockerhub-user
TAG=vX.Y.Z
NUXT_PUBLIC_API_BASE=https://api.yourdomain.com
CORS_ORIGIN=https://yourdomain.com
PUBLIC_BASE_URL=https://api.yourdomain.com
JWT_SECRET=change-medocker compose --env-file .env.prod -f docker-compose.prod.yml pull
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml psThe API persists uploads in the named volume api_data mounted at /app/data. No database service is required for the current production app.
- Create an Ubuntu Droplet with Docker Engine and Docker Compose plugin installed.
- Create a non-root deploy user and add it to the
dockergroup. - Copy
docker-compose.prod.ymland.env.prodto the server. - Log in to Docker Hub on the Droplet with
docker login. - Keep only ports
80and443open publicly; the compose file binds app ports to127.0.0.1only. - Pull and start with
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d. - Put Nginx in front of
127.0.0.1:5000and127.0.0.1:3000, then issue TLS certificates with Let's Encrypt. - Store
JWT_SECRETonly in.env.prodon the server and rotate it if a token is ever exposed.
Terminate TLS at Nginx and proxy to the internal ports:
- Web:
https://yourdomain.com->http://127.0.0.1:5000 - API:
https://api.yourdomain.com->http://127.0.0.1:3000
Minimal server blocks:
server { listen 80; server_name yourdomain.com; return 301 https://$host$request_uri; }
server { listen 80; server_name api.yourdomain.com; return 301 https://$host$request_uri; }
server {
listen 443 ssl; server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://127.0.0.1:5000; }
}
server {
listen 443 ssl; server_name api.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.yourdomain.com/privkey.pem;
client_max_body_size 20m;
location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://127.0.0.1:3000; }
location /uploads/ { proxy_pass http://127.0.0.1:3000; expires 7d; add_header Cache-Control "public, max-age=604800, immutable"; }
}Set NUXT_PUBLIC_API_BASE and PUBLIC_BASE_URL to the public API domain so og:image uses absolute URLs. Validate a post URL with Twitter Card Validator after deployment.
# From repo root
npm run test:all
# Web dev
cd apps/web && npm i && npm run dev
# API dev
cd ../api && npm i && npm run devBuild the hardened images locally and run the production compose stack with localhost-friendly env values:
cp .env.prod.example .env.prod.localEdit .env.prod.local to use:
DOCKER_NS=local
TAG=dev
WEB_DOMAIN=localhost
API_DOMAIN=localhost
NUXT_PUBLIC_API_BASE=http://127.0.0.1:3000
CORS_ORIGIN=http://127.0.0.1:5000,http://localhost:5000
PUBLIC_BASE_URL=http://127.0.0.1:3000
JWT_SECRET=change-me-nowThen build and run:
docker build -t local/glyph-api:dev -f apps/api/Dockerfile apps/api
docker build -t local/glyph-web:dev -f apps/web/Dockerfile apps/web
docker compose --env-file .env.prod.local -f docker-compose.prod.yml up -d
docker compose --env-file .env.prod.local -f docker-compose.prod.yml ps
curl -i http://127.0.0.1:3000/api/health
curl -I http://127.0.0.1:5000/The E2E tests run the Nuxt app locally and target the API running in Docker.
- The frontend server runs on
FRONTEND_PORT(default5999). - The tests and the Nuxt app talk to the Docker-exposed API via
API_BASE(defaulthttp://localhost:3388). - Real JWT tokens are generated in tests using
JWT_SECRET(HS256).
Add these to your .env (see .env.example):
JWT_SECRET=change_me_in_local_env
API_BASE=http://localhost:3388
FRONTEND_PORT=5999
# Optional: used by Nuxt runtime as well
NUXT_PUBLIC_API_BASE=http://localhost:3388# 1) Start API (in another terminal)
docker compose up -d api
# 2) From repo root, run Playwright tests
cd apps/web
npm i
npx playwright install --with-deps
npm run test:e2eNotes:
- Tests will skip if
JWT_SECRETis missing. - The Playwright config loads env from the repo root
.envand starts only Nuxt. - If port 5999 is busy, set
FRONTEND_PORTto another free port in.env.
.github/workflows/ci.ymldefines ane2ejob that:- Brings up
apiviadocker compose(API exposed on host:3388). - Seeds a CI
.envwithJWT_SECRET(fromE2E_JWT_SECRETsecret),API_BASE, andNUXT_PUBLIC_API_BASE. - Installs Playwright browsers and runs the E2E suite with the frontend on
FRONTEND_PORT=5999. - Tears down Docker after completion.
- Brings up
- Web (SEO):
- Replace the generic homepage
Postsmetadata with the configured blog name, a real meta description, stronger Open Graph and Twitter fields, and Blog structured data in SSR HTML. - Use the newest post’s first image as the homepage social image when available.
- Replace the generic homepage
- Web (markdown media):
- Add
support that renders a responsive embedded YouTube player. - Keep social-image extraction focused on actual images by ignoring video embeds.
- Add
- Tests / build:
- Extend content tests for YouTube video embeds.
- Web
typecheck, unit tests, and production build pass.
- Deploy:
- Refresh
.env.prod.exampleto point tov0.2.20.
- Refresh
- Web (paste uploads):
- Fix clipboard image pastes so multiple pasted local photos are uploaded and inserted, instead of only the first image being picked up.
- Prevent raw local filesystem paths from being pasted into the editor when image files are present on the clipboard.
- Web (type safety):
- Add local typing for
markdown-itand tighten post/metric typing so strict Nuxt typecheck passes in CI.
- Add local typing for
- Tests / build:
- Extend paste-media tests for multi-image clipboard uploads.
- Web
typecheckand unit tests pass.
- Deploy:
- Refresh
.env.prod.exampleto point tov0.2.19.
- Refresh
- Web (reading flow):
- Treat a standalone
---line inside a post as a homepage teaser cutoff so feed cards stay shorter and defer the rest of the post to the single-post page. - Add a soft fade and
Read Moreaction on truncated homepage cards.
- Treat a standalone
- Web (auth UX):
- Make the JWT auth modal submit on
Enterinstead of inserting a newline.
- Make the JWT auth modal submit on
- Tests / build:
- Extend web content tests for teaser splitting.
- Web unit tests and production build pass.
- Deploy:
- Refresh
.env.prod.exampleto point tov0.2.18.
- Refresh
- Web (stability):
- Make homepage cards visible by default and only apply reveal hiding to genuinely off-screen cards, preventing the feed from appearing blank until interaction or late hydration.
- Normalize single-post timestamp formatting to a fixed locale and UTC timezone so SSR and hydration produce the same text.
- Remove the transient
Post not foundflash when navigating back from a post to the homepage during route teardown.
- Deploy:
- Refresh
.env.prod.exampleto point tov0.2.17.
- Refresh
- Web (markdown):
- Replace the hand-rolled content regex renderer with a real Markdown pipeline using
markdown-it. - Support headings, lists, emphasis, paragraphs, links, and explicit Markdown images while preserving existing upload image behavior.
- Stop treating bare image URLs as images; image intent is now explicit through
.
- Replace the hand-rolled content regex renderer with a real Markdown pipeline using
- Web (metadata):
- Add
NUXT_PUBLIC_SITE_URLand use it for absolute canonical and Open Graph URL generation instead of relying on inferred request origin.
- Add
- Tests / build:
- Update content and slug tests for the new Markdown and URL behavior.
- Web unit tests and production build pass with the new renderer.
- Deploy:
- Refresh
.env.prod.exampleto point tov0.2.16.
- Refresh
- Web (social cards):
- Make SSR post URLs resolve against forwarded host/protocol headers so canonical URLs and
og:urlare emitted as absolute URLs on the live site. - This brings the social metadata into a fully consistent shape for X/Twitter previews alongside the already-correct
og:imageandtwitter:imagetags.
- Make SSR post URLs resolve against forwarded host/protocol headers so canonical URLs and
- Tests:
- Extend the slug/url test coverage for forwarded-header SSR URL generation.
- Deploy:
- Refresh
.env.prod.exampleto point tov0.2.15.
- Refresh
- Web (social cards):
- Move single-post data loading and Open Graph / Twitter card metadata onto the SSR path so X can see
og:imageand render link previews with the post image.
- Move single-post data loading and Open Graph / Twitter card metadata onto the SSR path so X can see
- Metrics:
- Add persisted per-post
viewedandopenedcounters. - Count
viewedwhen a post card becomes visible on the home feed andopenedwhen a reader enters the single-post view. - Show the ratio inline on home cards as
viewed:opened.
- Add persisted per-post
- API:
- Add a public
POST /api/posts/:id/metricendpoint for metric increments and normalize legacy posts to include zeroed metrics.
- Add a public
- Deploy:
- Refresh
.env.prod.exampleto point tov0.2.14.
- Refresh
- Web (footer):
- Normalize the footer version label so
NUXT_PUBLIC_APP_VERSION=v0.2.12renders asv0.2.12instead ofvv0.2.12.
- Normalize the footer version label so
- CI / governance:
- Soft-disable governance glyph validation by default in CI and Glyph & Docs Guard while the separate
ava-sig/governancerepo is temporarily divergent. - Keep the governance-dependent jobs available behind the repo variable
ENABLE_GOVERNANCE_GLYPHS=1.
- Soft-disable governance glyph validation by default in CI and Glyph & Docs Guard while the separate
- Deploy:
- Refresh
.env.prod.exampleto point tov0.2.13.
- Refresh
- Web (sharing):
- Fix the X share button on the home feed so it generates absolute post URLs during SSR instead of falling back to relative
/p/...links. - Keep single-post and home-feed share links consistent by deriving the origin from Nuxt request context on the server.
- Update slug/share tests to cover SSR absolute URL generation.
- Fix the X share button on the home feed so it generates absolute post URLs during SSR instead of falling back to relative
- Docker / production hardening:
- Run both production containers as non-root users, keep the root filesystem read-only, drop Linux capabilities, and add container health checks in
docker-compose.prod.yml. - Add per-app
.dockerignorefiles to keep Docker build contexts lean and avoid shipping local artifacts into images. - Remove the unused Postgres service and dead database env wiring from local/prod compose, examples, docs, and CI so deploys match the file-backed API architecture.
- Run both production containers as non-root users, keep the root filesystem read-only, drop Linux capabilities, and add container health checks in
- API:
- Respect
PORTinsrc/server.jsand improve startup logging. - Tighten CORS behavior so production uses configured origins instead of wildcard access.
- Add basic security headers and restrict uploads to known image MIME types with clearer validation errors.
- Respect
- Web / theme defaults:
- Keep the server
defaultThemeauthoritative for unauthenticated users until they explicitly choose a local preference. - Keep explicit browser theme choices in
localStorage, but stop persisting server-provided defaults as if they were user overrides. - Add a focused store test covering guest defaults versus explicit local theme overrides.
- Keep the server
- Docs / release workflow:
- Expand the README with a DigitalOcean droplet checklist, local production smoke-test instructions, and updated deploy guidance for the hardened Docker setup.
- Refresh
.env.prod.exampleto point tov0.2.11and includeNUXT_PUBLIC_APP_VERSION=${TAG}for footer version labeling.
- API: add file-backed
settings.jsonwith{ defaultTheme, blogName }; exposeGET /api/settingsandPUT /api/settings(auth) and ensure settings file exists inensureStore(). - Web (theme & persistence):
- Persist admin-selected default theme via
PUT /api/settingsfrom header toggle. - Load server default theme for guests when no local preference.
- Persist blog name on title save; load blog name for guests when no local override.
- Persist admin-selected default theme via
- Web (UI):
- Modal theme-aware styling (panel/border/buttons/textarea using theme variables).
- Toasts readable on light theme; keep translucent look on dark theme.
- Post list: client fallback fetch when SSR misses, then
nextTick()+setupReveal()so cards render visibly; long words/URLs wrap safely. - New-post form, inputs, and buttons now theme-aware; card background visuals reduced to ~20% opacity.
- Governance: centralized glyph mesh updated (removed
GOV-SEC-001, addedGVN-006,GVN-008).
- API: RFC 6750-compliant auth errors (WWW-Authenticate) and specific codes (token_missing, token_invalid, token_expired, insufficient_scope, auth_required).
- Web: surface API auth errors via toast with friendly messages; footer shows app version via NUXT_PUBLIC_APP_VERSION.
- Docs: Quick Deploy guide for Docker Hub namespace
ava2016. - Governance: GOV-CORE-013 Post-Merge Ritual formalized; workflow auto-deletes merged branches and posts changelog reminder.
- CI/Vitest: remove unsupported CLI flags; scope via
apps/web/vitest.config.tstest.include. - CI: run
web-buildinsideapps/web; add diagnostics (commit SHA, scripts, Vitest version). - E2E: Dockerized API healthchecks and health wait loop for stable Playwright runs.
- Workflows:
Glyph & Docs Guardinstallsapps/apideps and adds diagnostics; roottest:allinstalls API deps. - Post-merge smoke workflow added to validate prod-like API URL.
- Docs: add Sprint End Ritual and changelog.
- API: RFC 6750-compliant auth errors (WWW-Authenticate) with specific codes (
token_missing,token_invalid,token_expired,insufficient_scope,auth_required). - Web: surface API auth errors via toast with friendly messages; add footer version label (
Blog vX.Y.Z).