CIRCLE Cyber

Contributing a Documentation Site

Step-by-step guide to add a new project documentation site to the Circle docs infrastructure.

Contributing a Documentation Site

This guide explains how to add a new project to the Circle documentation infrastructure (docs.circle-cyber.com). Each project is deployed as an independent Docker container under its own path (e.g. /my-project/), routed by Traefik, and built automatically by GitHub Actions.

Overview

docs.circle-cyber.com/          ← this portal (circle-rd/documentation)
docs.circle-cyber.com/upki-ca/  ← uPKI CA docs (circle-rd/upki-ca → docs-site/)
docs.circle-cyber.com/upki-ra/  ← uPKI RA docs (circle-rd/upki-ra → docs-site/)
docs.circle-cyber.com/my-proj/  ← your project

Each documentation site is a Docus project (Nuxt 4 layer) that lives inside its own repository under a docs-site/ subdirectory.

Step 1 — Create the docs-site/ directory

Inside your project repository, create the following structure:

docs-site/
├── .gitignore
├── Dockerfile
├── nuxt.config.ts
├── package.json
├── app/
│   └── app.config.ts
├── content/
│   ├── index.md          # Landing page
│   └── docs/             # Documentation pages
│       └── getting-started/
│           └── 1.introduction.md
└── server/
    └── routes/
        └── _health.get.ts

Step 2 — package.json

{
  "name": "my-project-docs",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "nuxt dev",
    "build": "nuxt build",
    "generate": "nuxt generate",
    "preview": "nuxt preview"
  },
  "dependencies": {
    "docus": "latest",
    "nuxt": "^4.0.0",
    "better-sqlite3": "^12.2.0"
  }
}

Step 3 — nuxt.config.ts

Replace my-project with your actual path slug:

export default defineNuxtConfig({
  extends: ["docus"],
  app: {
    baseURL: process.env.NUXT_APP_BASE_URL ?? "/my-project/",
  },
  site: {
    url: process.env.NUXT_SITE_URL ?? "https://docs.circle-cyber.com/my-project",
  },
  robots: {
    robotsTxt: false,
  },
  llms: {
    domain: "https://docs.circle-cyber.com",
    title: "My Project",
    description: "Short description of my project.",
  },
})

Step 4 — app/app.config.ts

export default defineAppConfig({
  seo: {
    title: "My Project",
    description: "Short description of my project.",
  },
  header: {
    title: "My Project",
  },
  socials: {
    github: "https://github.com/circle-rd/my-project",
  },
  github: {
    url: "https://github.com/circle-rd/my-project",
    branch: "main",
    rootDir: "docs-site",
  },
})

Step 5 — Dockerfile

# Stage 1 — build
FROM node:24-alpine AS builder

WORKDIR /app

RUN apk add --no-cache python3 make g++

ARG NUXT_APP_BASE_URL=/my-project/
ARG NUXT_SITE_URL=https://docs.circle-cyber.com/my-project

ENV NUXT_APP_BASE_URL=${NUXT_APP_BASE_URL}
ENV NUXT_SITE_URL=${NUXT_SITE_URL}

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npx nuxt build

# Stage 2 — runtime
FROM node:24-alpine

WORKDIR /app

COPY --from=builder /app/.output ./.output

ARG NUXT_APP_BASE_URL=/my-project/
ARG NUXT_SITE_URL=https://docs.circle-cyber.com/my-project

ENV HOST=0.0.0.0
ENV PORT=3000
ENV NODE_ENV=production
ENV NUXT_APP_BASE_URL=${NUXT_APP_BASE_URL}
ENV NUXT_SITE_URL=${NUXT_SITE_URL}

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
    CMD wget -qO /dev/null http://127.0.0.1:3000/_health || exit 1

CMD ["node", ".output/server/index.mjs"]

Step 6 — server/routes/_health.get.ts

Required for the Docker healthcheck:

export default defineEventHandler(() => ({ status: "ok" }))

Step 7 — .gitignore

.nuxt/
.output/
.data/
dist/
node_modules/
pnpm-lock.yaml
yarn.lock
.env
.env.*
npm-debug.log*
.DS_Store
.vercel

Step 8 — Write content

Landing page (content/index.md)

Important — MDC syntax rule: u-page-feature components must use inline props for title and description. Do not use named slots (#title, #description) inside ::::u-page-feature — this ambiguates the MDC parser and causes the entire file to be silently ignored, resulting in a 404.

---
seo:
  title: My Project — Tagline
  description: Short description.
---

:::u-page-hero
#title
My Project headline

#description
Longer description of the project.

#links
::::u-button{to="/docs/getting-started/introduction" size="xl" trailing-icon="i-lucide-arrow-right" color="neutral"}
Get Started
::::
:::

:::u-page-section
#title
Key Features

#features
::::u-page-feature{icon="i-lucide-shield" title="Feature A" description="Description of feature A."}
::::
::::u-page-feature{icon="i-lucide-zap" title="Feature B" description="Description of feature B."}
::::
:::

Documentation pages (content/docs/)

Files are ordered by numeric prefix (1.introduction.md, 2.installation.md). Docus generates navigation automatically. Use standard Markdown — MDC components are available for callouts, code groups, etc.

Step 9 — GitHub Actions workflow

Create .github/workflows/docs.yml in your repository:

name: Deploy Documentation

on:
  push:
    branches:
      - main
    paths:
      - "docs-site/**"
  workflow_dispatch:

permissions:
  contents: read
  packages: write

jobs:
  build-and-push:
    name: Build and push docs image
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

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

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: docs-site
          file: docs-site/Dockerfile
          push: true
          tags: |
            ghcr.io/${{ github.repository_owner }}/my-project-docs:latest
            ghcr.io/${{ github.repository_owner }}/my-project-docs:${{ github.sha }}
          build-args: |
            NUXT_APP_BASE_URL=/my-project/
            NUXT_SITE_URL=https://docs.circle-cyber.com/my-project
          cache-from: type=gha
          cache-to: type=gha,mode=max

Step 10 — Add to compose.yml

In the production compose.yml (managed in circle-rd/documentation), add a new service:

my-project-docs:
  image: ghcr.io/circle-rd/my-project-docs:latest
  restart: unless-stopped
  networks:
    - docs
  labels:
    - traefik.enable=true
    - traefik.http.routers.my-project-docs.rule=Host(`docs.circle-cyber.com`) && PathPrefix(`/my-project`)
    - traefik.http.routers.my-project-docs.entrypoints=websecure
    - traefik.http.routers.my-project-docs.tls.certresolver=letsencrypt
    - traefik.http.routers.my-project-docs.middlewares=docs-headers
    - traefik.http.services.my-project-docs.loadbalancer.server.port=3000

Then add a link to the landing page (content/index.md) in this portal:

::::u-page-feature{icon="i-lucide-box" title="My Project" description="Short description." to="https://docs.circle-cyber.com/my-project/"}
::::

Local development

cd docs-site
npm install
npx nuxt dev --port 3002   # pick any free port
# → http://localhost:3002/my-project/
Copyright © 2026