Docs CI/CD & Preview Deployments
This guide is part of Developer Portal Frameworks & UI Setup and covers the pipeline that turns a documentation change into a reviewable artifact and then into a published site: a disposable preview URL per pull request, automated gates that must pass before merge, and a promotion step that publishes the exact artifact that was reviewed. It applies to any static portal — Docusaurus, a Redoc build from Redocly & OpenAPI UI Configuration, or a Scalar static build. It does not cover the hosted platforms that supply their own previews; there the pipeline is theirs and your job is only to feed it a valid spec.
The problem this solves is specific. Documentation review without a preview is review of a diff, and a diff cannot show you a broken table, an operation that lost its sidebar entry, a code block whose highlighting swallowed a line, or a dark-mode contrast failure. Reviewers approve because the words look right, and the defect ships. A preview deployment converts every one of those into something a reviewer can see in the same tab as the comments.
Quick reference: preview hosting models
Every model below gives you a URL per pull request. They differ in who runs the build, whether previews can reach private infrastructure, and what cleanup you inherit.
| Model | Build runs in | Private/authed previews | Cleanup | Best when |
|---|---|---|---|---|
| Platform-native (Netlify, Vercel, Cloudflare Pages) | The platform | Password or access policy per deploy | Automatic on branch delete | The site is public and the platform already hosts production |
| CI-built, platform-deployed | Your CI, uploaded via CLI | Same as above | Automatic | You need custom build steps the platform cannot express |
| CI-built, object storage | Your CI | Bucket policy or signed URLs | You write it — lifecycle rules | Everything must stay inside your own cloud account |
| Ephemeral container per PR | Your CI/cluster | Full network control | You write it — namespace TTL | Previews must reach internal APIs for the try-it console |
| Artifact download only | Your CI | N/A — reviewer opens locally | Automatic | Small teams, or a hard prohibition on external hosting |
The bottom row is a real option and is often dismissed too quickly. Uploading the built site as a CI artifact costs nothing, leaks nothing, and works everywhere — it just asks the reviewer to download and open a file, which is enough friction that most people will not do it. Treat it as the fallback when policy forbids the others, not as a first choice.
The distinction that actually matters is the fourth column of the previous paragraph’s decision: whether the preview needs to reach a private API. A docs portal with a working try-it console pointed at an internal staging service cannot be usefully previewed on a public platform host, because the console will fail CORS or DNS from outside your network. That single requirement pushes you to ephemeral containers or in-account object storage regardless of every other consideration.
Prerequisites & Environment Setup
You need a static build, a host that accepts multiple concurrent deployments, and CI with access to both.
- A deterministic build.
npm cirather thannpm install, a committed lockfile, and a pinned Node version. A preview that cannot be reproduced is not evidence of anything. - A base-URL-aware build. The site must accept its own public URL as an input, because a preview lives at a different origin than production. Most generators expose this as a config field or environment variable.
- A host with per-deployment URLs. Either a platform that assigns one automatically, or a bucket/prefix scheme you control.
- Credentials in CI secrets, scoped to deploy-only where the provider supports it.
# .nvmrc — pin the runtime so local and CI agree
20.11.1
# Verify the build is reproducible before wiring any of it into CI
npm ci
npm run build
du -sh dist/ # note the size; a sudden change is a signal
npx serve dist -l 4173 # open http://localhost:4173 and click through
Set the base URL from the environment rather than hardcoding it. For a Docusaurus site:
// docusaurus.config.js
const url = process.env.DOCS_URL || 'https://docs.example.com';
module.exports = {
url, // origin — differs per preview
baseUrl: process.env.DOCS_BASE_URL || '/', // path prefix — '/' unless deploying to a subdirectory
// Broken links must fail the build. A preview that hides 404s is worse than none.
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'throw',
};
The onBrokenLinks: 'throw' line is the single highest-value setting in this file. Documentation accumulates broken internal links faster than any other defect, and the only reliable moment to catch one is the build that created it.
Core Configuration
The pipeline has three jobs: build, preview-deploy, and promote. Keep them separate so the promotion step can deploy an artifact it did not build.
# .github/workflows/docs.yml
name: Docs
on:
pull_request:
paths: ['docs/**', 'openapi/**', 'package.json', 'docusaurus.config.js']
push:
branches: [main]
paths: ['docs/**', 'openapi/**', 'package.json', 'docusaurus.config.js']
permissions:
contents: read
pull-requests: write # to post the preview URL as a comment
id-token: write # OIDC federation to the cloud account — no long-lived keys
concurrency:
# One run per branch; a new push cancels the previous preview build.
group: docs-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- run: npm ci
# Fetch the contract at a pinned tag rather than copying it into this repo.
- name: Fetch OpenAPI contract
run: ./scripts/fetch-spec.sh
env:
API_SPEC_VERSION: ${{ vars.API_SPEC_VERSION }}
- name: Lint the contract
run: npx @redocly/cli@2 lint openapi/api.yaml
- name: Build
run: npm run build
env:
# Previews get their own origin; production uses the canonical one.
DOCS_URL: ${{ github.event_name == 'pull_request'
&& format('https://pr-{0}.docs-preview.example.com', github.event.number)
|| 'https://docs.example.com' }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: site-${{ github.sha }}
path: dist/
retention-days: 7
Three choices in that file are worth calling out. The concurrency block with cancel-in-progress means a reviewer always sees the newest commit rather than racing builds. The id-token: write permission enables OIDC federation so the deploy step assumes a short-lived cloud role instead of using a stored access key. And the artifact name includes the commit SHA, which is what makes promotion-without-rebuilding possible: the promote job downloads a named artifact rather than running the build again.
Integration Pattern
The preview job downloads the artifact and publishes it under a per-pull-request prefix, then comments the URL back onto the pull request so reviewers do not have to hunt for it.
preview:
needs: build
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: site-${{ github.sha }}
path: dist
- name: Assume deploy role
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.DOCS_PREVIEW_ROLE_ARN }}
aws-region: eu-west-1
- name: Publish preview
run: |
aws s3 sync dist/ "s3://docs-previews/pr-${{ github.event.number }}/" \
--delete \
--cache-control 'no-store' # previews must never be cached
- name: Comment the preview URL
uses: actions/github-script@v7
with:
script: |
const url = `https://pr-${context.issue.number}.docs-preview.example.com/`;
const marker = '<!-- docs-preview -->';
const body = `${marker}\n📄 Docs preview: ${url}`;
const {data: comments} = await github.rest.issues.listComments(
{...context.repo, issue_number: context.issue.number});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment(
{...context.repo, comment_id: existing.id, body});
} else {
await github.rest.issues.createComment(
{...context.repo, issue_number: context.issue.number, body});
}
Updating an existing comment rather than adding a new one keeps the pull request readable — a twenty-commit branch otherwise accumulates twenty identical preview links. The --delete flag on the sync matters too: without it, a file removed in a later commit lingers in the preview and reviewers validate a page that will not exist after merge.
Promotion deploys the same artifact to production, which is what makes the review meaningful:
promote:
needs: build
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production # add required reviewers here if you want a manual gate
steps:
- uses: actions/download-artifact@v4
with:
name: site-${{ github.sha }}
path: dist
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.DOCS_PROD_ROLE_ARN }}
aws-region: eu-west-1
- name: Publish
run: |
# Hashed assets are immutable and cached hard; HTML must revalidate.
aws s3 sync dist/ s3://docs-production/ --delete \
--exclude '*.html' --cache-control 'public,max-age=31536000,immutable'
aws s3 sync dist/ s3://docs-production/ --delete \
--exclude '*' --include '*.html' --cache-control 'public,max-age=0,must-revalidate'
aws cloudfront create-invalidation \
--distribution-id ${{ vars.CF_DISTRIBUTION_ID }} --paths '/*'
That two-pass sync is the caching pattern every static docs site wants, and getting it wrong is the most common cause of “I deployed but users see the old page”. Caching and CDN headers for docs sites works through the header matrix in detail.
Advanced Options
Gate the merge, not just the build. Attaching checks to the preview URL is what turns a preview from a courtesy into a control. Run the link checker and an accessibility pass against the deployed preview rather than against local files, because only the deployed version exercises real URLs, redirects, and headers:
- name: Check links on the preview
uses: lycheeverse/lychee-action@v2
with:
args: >-
--no-progress --max-concurrency 8
--exclude-mail
https://pr-${{ github.event.number }}.docs-preview.example.com/sitemap.xml
Pointing the checker at the sitemap rather than the root means it validates every published page, including ones no navigation currently links to. Broken link checking in docs CI covers rate limiting, allowlists, and handling third-party links that flap.
Keep previews out of search. A preview host that gets indexed competes with production for its own content and can leak unreleased material. Serve a header from the preview origin — not a robots.txt, which is easy to forget and which some crawlers treat as advisory for already-known URLs:
X-Robots-Tag: noindex, nofollow
Expire previews automatically. Every preview is storage and surface area you did not intend to keep. A bucket lifecycle rule that deletes objects under pr-*/ after fourteen days costs nothing and prevents a slow accumulation of stale, publicly reachable copies of your documentation:
{
"Rules": [{
"ID": "expire-docs-previews",
"Status": "Enabled",
"Filter": {"Prefix": "pr-"},
"Expiration": {"Days": 14}
}]
}
Scope the trigger. The paths filter on both triggers means a change to unrelated application code does not rebuild and redeploy the documentation. On a monorepo this is the difference between a docs pipeline that runs a few times a day and one that runs on every commit to anything.
Get the cache headers right per asset class. The three classes below need three different policies, and mixing them up produces either a site that never updates or one that re-downloads its whole bundle on every navigation:
Verification & Testing
Verify the pipeline itself, not just the site it produces. Open a throwaway pull request that makes a deliberately visible change and walk the whole path.
git checkout -b docs/pipeline-smoke-test
printf '\n<!-- preview smoke test marker -->\n' >> docs/index.md
git commit -am 'test: docs preview smoke test' && git push -u origin HEAD
gh pr create --fill
Then assert each property in turn:
PR=$(gh pr view --json number -q .number)
BASE="https://pr-${PR}.docs-preview.example.com"
# 1. The preview is live and serves the change
curl -sf "${BASE}/" | grep -q 'preview smoke test marker' \
&& echo 'OK preview serves the new content'
# 2. The preview is not indexable
curl -sI "${BASE}/" | grep -qi 'x-robots-tag: *noindex' \
&& echo 'OK preview is noindex'
# 3. The preview is not cached
curl -sI "${BASE}/" | grep -qi 'cache-control: *no-store' \
&& echo 'OK preview is uncached'
# 4. Production is untouched
curl -sf https://docs.example.com/ | grep -q 'preview smoke test marker' \
&& echo 'FAIL production changed before merge' || echo 'OK production unchanged'
Expected output from a healthy pipeline:
OK preview serves the new content
OK preview is noindex
OK preview is uncached
OK production unchanged
Then merge, and confirm the promotion deployed the same artifact rather than rebuilding — compare the checksum recorded by the build job with the one served in production. A mismatch means something in the promote path is rebuilding, and your review guarantee is not real. Close by deleting the branch and confirming the preview prefix disappears (immediately, if you wired a cleanup job; within the lifecycle window otherwise).
Troubleshooting
The preview renders but every internal link 404s. The build baked in the production base URL while the site is served from the preview origin, or vice versa. Confirm DOCS_URL reached the build step — a ${{ }} expression that evaluates to an empty string produces exactly this symptom and does not fail the build. Echo the resolved value in the build log.
Assets load from production while HTML comes from the preview. The generator emitted absolute asset URLs using the canonical origin. This makes previews unreliable in a subtle way: you review new HTML against old CSS. Use root-relative asset paths, or ensure the origin variable feeds asset URL generation as well as page URLs.
Reviewers see a stale preview. Either the CDN in front of the preview host is caching (fix with no-store, as above) or two builds raced and the older one finished last. The concurrency group with cancel-in-progress: true prevents the race.
Access Denied on the deploy step, only for pull requests from forks. Fork pull requests deliberately receive a read-only token and no secrets, so the deploy cannot authenticate. This is correct behaviour, not a bug — a fork could otherwise exfiltrate your deploy credentials. Either use a pull_request_target workflow that builds the base branch’s pipeline against the fork’s content with careful review, or accept that external contributions get artifact-only previews.
The link checker passes locally and fails against the preview. Local checking walks the filesystem, where /guides/auth resolves to guides/auth/index.html regardless of server configuration. The deployed host may or may not do that rewrite, so a link that works on disk can 404 over HTTP. This is exactly the class of defect a preview exists to catch — fix the host’s directory-index behaviour or emit explicit trailing slashes, and keep checking against the deployed URL rather than falling back to the local pass.
The promote job cannot find the artifact. Artifacts are scoped to a workflow run, so a promote job in a different run cannot download one uploaded by the pull request run. Either promote from within the same run (triggered by the push to main after merge, which rebuilds) or publish the artifact to a registry or bucket that outlives the run. Decide deliberately which of those two you want; the second preserves the build-once guarantee, the first is simpler.
FAQ
Should a preview deployment build the same artifact as production?
Yes, with only the base URL differing. If the preview runs a different build command or skips steps such as search indexing, the preview stops predicting production and reviewers approve something that was never actually built. Change the environment, never the pipeline.
How do I stop preview deployments leaking unreleased documentation?
Serve previews from a hostname that is excluded from search engines with an X-Robots-Tag: noindex header, and put access control in front of the preview host. Never rely on an unguessable URL, because previews are linked from pull requests that may be public and from CI logs that may be retained.
Do I need to rebuild the whole site for every preview?
For most documentation sites a full rebuild takes under two minutes and is simpler than incremental builds, so rebuild everything. Reach for incremental or cached builds only once full builds exceed the time reviewers will wait, and cache the dependency install before you start caching build output — the install is usually the larger share.
Where should the OpenAPI spec live relative to the docs pipeline?
In the repository that owns the API, fetched into the docs build at a pinned version. A copy inside the docs repository silently forks the moment someone edits it, and the reference then describes an API that does not exist. Bump the pinned version as an explicit commit so each docs release records which contract it documents.
Related
- Developer Portal Frameworks & UI Setup — the parent overview of portal tooling.
- Deploying docs previews on pull requests — the per-PR preview walkthrough.
- Caching and CDN headers for docs sites — the header matrix for immutable assets and revalidated HTML.
- Broken link checking in docs CI — gating merges on link integrity.
- Docusaurus for API Portals and Interactive API Consoles & Try-It Panels — what the pipeline publishes.