How This Site Is Hosted
The site you’re reading is served by the stack it documents. The deployment is also designed to be liftable to Vercel with zero code changes the day you want to flip it.
Today: in-cluster
git.skypalette.ai/skyadmin/sky-palette-infra-tutorial
│
│ push to main
▼
Forgejo Actions: `npm ci && npm run build`
│
│ produces `out/` (static export, no SSR)
▼
Docker build: nginx:alpine + `out/`
│
│ push to git.skypalette.ai/v2/sky-palette-infra-tutorial:<sha>
▼
argocd-image-updater: bumps tag in gitops repo
│
▼
ArgoCD: syncs new image to the cluster
│
▼
tutorial.skypalette.ai (via cloudflared → ingress-nginx → cert-manager TLS)The Next.js config that makes both targets work
// next.config.mjs
import nextra from 'nextra'
const withNextra = nextra({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.tsx',
})
export default withNextra({
output: 'export', // produces a static `out/` directory
images: { unoptimized: true },
trailingSlash: true, // matches nginx + Vercel's default routing
})output: 'export' is the whole trick. The build artifact is a directory
of HTML/JS/CSS — no Node runtime needed, no API routes, no
Vercel-specific features. Anything that serves static files can serve
this site.
The Dockerfile
# Build stage — Node 20, pinned, no telemetry.
FROM node:20-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Serve stage — nginx, just the static output.
FROM nginx:alpine
COPY --from=builder /app/out /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080The nginx config sets listen 8080; (so the container runs as non-root)
and adds a single try_files directive so /foo falls back to
/foo/index.html matching Next’s trailingSlash: true output.
Migrating to Vercel — what changes
git.skypalette.ai/skyadmin/sky-palette-infra-tutorial
│
│ push to main (or mirror to GitHub if Vercel needs it)
▼
Vercel: detects Next.js, runs `npm run build`
│
▼
Vercel CDN: serves the same static export, globally cached
│
▼
tutorial.skypalette.ai (CNAME flipped to Vercel)Concrete migration steps the day you decide:
vercel linkfrom this repo’s working tree. Vercel detects Next.js automatically.- Set the project’s “Output Directory” to
outif it doesn’t auto-detect fromoutput: 'export'. vercel --prod→ Vercel issues a*.vercel.appURL.- In Cloudflare DNS, change
tutorialfrom CNAMEd to the in-cluster tunnel to CNAMEd tocname.vercel-dns.com. Vercel handles its own TLS. - Delete:
- The
Dockerfileandnginx.conf(Vercel doesn’t use them). - The Forgejo Actions workflow (Vercel builds on its own).
- The gitops
base/sky-palette-infra-tutorial/+ overlay + Application.
- The
- Keep:
- Everything else. The Next.js source is identical.
Why static export, not SSR
- Portability. Static HTML serves identically on any CDN, any nginx, any S3 bucket. SSR ties you to Node-on-host or to Vercel Functions / AWS Lambda.
- Zero runtime cost. No Node process to OOM, no scaling decisions. An empty nginx pod handles a hundred concurrent readers without breathing hard.
- Vercel-compatible by default. Vercel happily serves static exports, with the same CDN edge caching it gives full Next.js apps.
The tradeoff: no per-request data, no search indexes built at runtime, no auth-gated pages. For a docs site, that’s a perfect fit.
”Where’s the search built?”
Nextra’s flexsearch index is generated at build time. The whole search corpus lives in a JSON blob shipped with the static export — no search service required, no API endpoint, no API rate limits. The tradeoff is bundle size; for this site it’s a few hundred KB and worth it.