Deploying Docs Previews on Pull Requests
This guide is part of Docs CI/CD & Preview Deployments within Developer Portal Frameworks & UI Setup. It walks through publishing a disposable, fully rendered copy of the documentation for every pull request, so reviewers read the site rather than the diff. The need shows up the first time a reviewer approves a change that looked correct in Markdown and rendered badly — a table that overflowed, a code fence that swallowed a line, an operation that lost its sidebar entry.
Problem & Context
Without previews, documentation review is textual. A reviewer sees this in the diff:
-| Parameter | Type | Description |
-| --- | --- | --- |
-| `cursor` | string | Opaque pagination cursor |
+| Parameter | Type | Required | Description | Example |
+| --- | --- | --- | --- | --- |
+| `cursor` | string | no | Opaque pagination cursor returned by the previous page | `eyJvIjoxMjB9` |
That reads as an improvement, and it is — but on a 920-pixel content column the five-column version overflows, and whether it scrolls cleanly or clips depends on CSS the author never looked at. The reviewer cannot know. Neither can the author, unless one of them builds and serves the site locally, which in practice nobody does for a table change.
The same blindness covers everything that is a property of the rendered page rather than the source: heading anchors that collide after a rename, an inline SVG whose labels overlap once the site’s font applies, a dark-mode contrast failure, a link that resolves on disk and 404s over HTTP. A preview URL makes all of them visible in the same place the review is happening, at the cost of one CI job.
Step-by-Step Solution
1. Make the base URL an input
The build must not know whether it is producing a preview or production. Read the origin from the environment and default to production, so a local build still works:
// docusaurus.config.js — the same shape applies to any generator
const url = process.env.DOCS_URL || 'https://docs.example.com';
module.exports = {
url,
baseUrl: process.env.DOCS_BASE_URL || '/',
onBrokenLinks: 'throw',
};
Verify it takes effect before wiring any CI:
DOCS_URL=https://pr-482.docs-preview.example.com npm run build
grep -o 'https://[a-z0-9.-]*' dist/sitemap.xml | sort -u | head -1
https://pr-482.docs-preview.example.com
If that prints the production origin, the variable is not reaching the generator and every later step will produce a preview that links to production.
2. Build once and upload the artifact
One build, uploaded as an artifact keyed on the commit. Later jobs deploy it rather than rebuilding, which is what makes “what was reviewed is what ships” true rather than aspirational:
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
- name: Build
run: npm run build
env:
DOCS_URL: https://pr-${{ github.event.number }}.docs-preview.example.com
- name: Show the origin that was baked in
run: grep -m1 -o 'https://[a-z0-9.-]*' dist/sitemap.xml
- uses: actions/upload-artifact@v4
with:
name: site-${{ github.sha }}
path: dist/
retention-days: 7
3. Deploy to a per-PR prefix
Sync the artifact under a prefix derived from the pull request number. --delete matters: without it, a file removed in a later commit survives in the preview and reviewers validate a page that will not exist after merge.
preview:
needs: build
runs-on: ubuntu-latest
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_PREVIEW_ROLE_ARN }}
aws-region: eu-west-1
- name: Sync
run: |
aws s3 sync dist/ "s3://docs-previews/pr-${{ github.event.number }}/" \
--delete --cache-control 'no-store'
4. Comment the preview URL
Reviewers should not have to open the Actions tab to find the link. Post one comment and update it on every push:
- uses: actions/github-script@v7
with:
script: |
const marker = '<!-- docs-preview -->';
const url = `https://pr-${context.issue.number}.docs-preview.example.com/`;
const body = `${marker}\n📄 **Docs preview:** ${url}\n\nBuilt from \`${context.sha.slice(0,7)}\`.`;
const {data: comments} = await github.rest.issues.listComments(
{...context.repo, issue_number: context.issue.number});
const prev = comments.find(c => c.body.includes(marker));
if (prev) await github.rest.issues.updateComment(
{...context.repo, comment_id: prev.id, body});
else await github.rest.issues.createComment(
{...context.repo, issue_number: context.issue.number, body});
5. Clean up on close
A separate workflow on the closed event removes the prefix. Add a storage lifecycle rule as a backstop, because cleanup jobs fail silently more often than anyone expects:
# .github/workflows/preview-cleanup.yml
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.DOCS_PREVIEW_ROLE_ARN }}
aws-region: eu-west-1
- run: aws s3 rm "s3://docs-previews/pr-${{ github.event.number }}/" --recursive
Complete Working Example
One file that does all five steps:
# .github/workflows/docs-preview.yml
name: Docs preview
on:
pull_request:
paths: ['docs/**', 'openapi/**', 'package.json']
permissions:
contents: read
pull-requests: write
id-token: write
concurrency:
group: docs-preview-${{ github.event.number }}
cancel-in-progress: true
jobs:
preview:
runs-on: ubuntu-latest
env:
PREVIEW_URL: https://pr-${{ github.event.number }}.docs-preview.example.com
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version-file: '.nvmrc', cache: 'npm' }
- run: npm ci
- name: Build with the preview origin
run: npm run build
env:
DOCS_URL: ${{ env.PREVIEW_URL }}
- name: Fail if the wrong origin was baked in
run: |
grep -q "${PREVIEW_URL}" dist/sitemap.xml \
|| { echo "Build used the wrong origin"; exit 1; }
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.DOCS_PREVIEW_ROLE_ARN }}
aws-region: eu-west-1
- name: Publish
run: |
aws s3 sync dist/ "s3://docs-previews/pr-${{ github.event.number }}/" \
--delete --cache-control 'no-store'
- name: Comment the link
uses: actions/github-script@v7
env:
PREVIEW_URL: ${{ env.PREVIEW_URL }}
with:
script: |
const marker = '<!-- docs-preview -->';
const body = `${marker}\n📄 **Docs preview:** ${process.env.PREVIEW_URL}/\n\n` +
`Built from \`${context.sha.slice(0,7)}\`.`;
const {data: comments} = await github.rest.issues.listComments(
{...context.repo, issue_number: context.issue.number});
const prev = comments.find(c => c.body.includes(marker));
if (prev) await github.rest.issues.updateComment(
{...context.repo, comment_id: prev.id, body});
else await github.rest.issues.createComment(
{...context.repo, issue_number: context.issue.number, body});
The bucket needs no-store on every object and an X-Robots-Tag: noindex header from the CDN in front of it. Neither is optional: the first keeps reviewers from seeing a cached older push, the second keeps unreleased documentation out of search results.
Gotchas & Edge Cases
Fork pull requests get no secrets. GitHub deliberately withholds secrets and gives a read-only token to workflows triggered by a fork, so the deploy step cannot authenticate. This is correct — otherwise any stranger could exfiltrate your deploy credentials by opening a pull request. Accept artifact-only previews for external contributions, or run a separate, carefully scoped pull_request_target workflow and review it as security-sensitive code.
Two pushes race and the older one wins. Without a concurrency group, a fast second build can finish before a slow first one and leave the preview showing the earlier commit. The concurrency block keyed on the pull request number with cancel-in-progress: true removes the race entirely.
Absolute asset URLs point at production. Some generators emit <link href="https://docs.example.com/assets/..."> regardless of the page origin, so the preview loads new HTML with the production stylesheet — a preview that looks right while hiding exactly the CSS change under review. Check one built page’s asset URLs after step 1, and switch the generator to root-relative asset paths if they are absolute.
Preview URLs outlive the pull request in other people’s notes. A reviewer pastes the link into a chat thread, someone bookmarks it, a ticket references it — and three weeks after the branch merged those links either 404 or, worse, still serve a stale copy of the documentation that nobody is maintaining. The event-driven cleanup handles the first case correctly; make sure the preview host returns a plain, unstyled 404 rather than a generic platform error page, so anyone following an old link immediately understands the preview expired rather than assuming the documentation was deleted. A one-line redirect from any missing preview path to the production site is friendlier still.
A preview is not a staging environment. It is tempting, once per-PR previews work, to point them at real backing services so the try-it console does something interesting. Resist unless the console genuinely needs it, because every service a preview touches becomes something a pull request from any contributor can reach. Keep previews reading static content and, where a console is present, point it at a mock rather than at a live API. The preview’s job is to show what the documentation looks like, not to be a second copy of the product.
FAQ
Why does my preview show production content?
The build baked in the production origin because the base-URL environment variable did not reach the build step. An unset GitHub Actions expression evaluates to an empty string rather than failing, so echo the resolved value in the build log and assert it against the expected preview URL, as the example workflow does.
Should previews be publicly reachable?
Only if the documentation is already public. Even then, serve an X-Robots-Tag: noindex header so previews never compete with production in search results or leak unreleased pages through a crawler. For anything not yet public, put an access policy in front of the preview host rather than relying on an unguessable URL.
How do I keep the pull request from filling with preview comments?
Write an HTML marker comment into the body, search existing comments for it, and update that comment instead of creating a new one. A twenty-commit branch then carries one link rather than twenty, and the link always describes the newest build.
Related
- Docs CI/CD & Preview Deployments — the parent guide and the promotion step.
- Broken link checking in docs CI — the gate to run against the preview URL.
- Caching and CDN headers for docs sites — why previews use
no-storeand production does not. - Developer Portal Frameworks & UI Setup — the parent overview.