How to Publish TechDocs to S3 with CI

Publishing TechDocs from CI to an S3 bucket is the production-grade alternative to letting the Backstage backend render documentation on demand. By moving the MkDocs build into your pipeline and uploading static HTML to object storage, you keep the portal backend lightweight and make documentation freshness a property of your CI, not your runtime. This how-to fits inside the broader TechDocs Documentation Pipelines workflow and the wider Developer Portal Architecture & Frameworks strategy.

Prerequisites

  • Node.js 20+ and @techdocs/cli 1.8.13 available on the CI runner.
  • Python 3.11+ with mkdocs-techdocs-core 1.3.3 for the generator.
  • An S3 bucket (e.g. ${TECHDOCS_S3_BUCKET}) with versioning enabled and public access blocked.
  • An AWS IAM role assumable via GitHub OIDC — no long-lived access keys.
  • Backstage configured with techdocs.builder: 'external' and publisher.type: 'awsS3', as set up in the parent pipeline guide.

The credential model is the part worth getting right first: the CI job assumes a short-lived IAM role through GitHub OIDC, so no long-lived access keys ever live in the repository or the runner.

Keyless publish via GitHub OIDC The CI job presents an OIDC token, assumes a scoped IAM role, and writes only to the documentation prefix. CI job OIDC token Assume IAM role short-lived Scoped policy prefix only S3 bucket write docs
No stored keys: the role is assumed per run and scoped to a single documentation prefix.

The IAM role needs only the object-level permissions for the documentation prefix:

// techdocs-publish-policy.json — least-privilege publish policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::${TECHDOCS_S3_BUCKET}",
        "arn:aws:s3:::${TECHDOCS_S3_BUCKET}/*"
      ]
    }
  ]
}

Exact Configuration

The publish job runs a fixed sequence: check out the repo, install the toolchain, assume the OIDC role, generate the site, and publish it under the entity-namespaced prefix. The diagram traces that chain so you can see where each workflow step fits.

TechDocs publish workflow sequence Checkout, install toolchain, assume the role, generate the site, and publish to the namespaced prefix. Checkout Install cli + mkdocs Assume role OIDC Generate --no-docker Publish --entity prefix
The --entity value on the final step must match the catalog entity exactly, or the backend serves a 404.
  1. Configure the publisher in app-config.yaml. The backend reads from the same bucket the CI job writes to.

    # app-config.yaml — requires @backstage/plugin-techdocs-backend >= 1.10.0
    techdocs:
      builder: 'external'
      publisher:
        type: 'awsS3'
        awsS3:
          bucketName: ${TECHDOCS_S3_BUCKET}
          region: ${AWS_REGION}
    
  2. Add the publish workflow. This GitHub Actions job assumes the OIDC role, generates the site, and publishes it under the entity-namespaced prefix the backend expects.

    # .github/workflows/techdocs.yml — requires actions/checkout@v4
    name: Publish TechDocs
    on:
      push:
        branches: [main]
    permissions:
      id-token: write     # required for OIDC role assumption
      contents: read
    env:
      TECHDOCS_S3_BUCKET: ${{ vars.TECHDOCS_S3_BUCKET }}
      AWS_REGION: ${{ vars.AWS_REGION }}
      ENTITY_NAMESPACE: default
      ENTITY_KIND: component
      ENTITY_NAME: payments-api
    jobs:
      publish-techdocs:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-python@v5
            with:
              python-version: '3.11'
          - uses: actions/setup-node@v4
            with:
              node-version: '20'
          - name: Install toolchain
            run: |
              pip install mkdocs-techdocs-core==1.3.3
              npm install -g @techdocs/[email protected]
          - name: Configure AWS credentials
            uses: aws-actions/configure-aws-credentials@v4
            with:
              role-to-assume: ${{ secrets.TECHDOCS_OIDC_ROLE_ARN }}
              aws-region: ${{ env.AWS_REGION }}
          - name: Generate
            run: techdocs-cli generate --no-docker --source-dir . --output-dir ./site --verbose
          - name: Publish
            run: |
              techdocs-cli publish \
                --publisher-type awsS3 \
                --storage-name ${TECHDOCS_S3_BUCKET} \
                --entity ${ENTITY_NAMESPACE}/${ENTITY_KIND}/${ENTITY_NAME} \
                --directory ./site
    
  3. Pass --no-docker to the generator so the build runs against the locally installed MkDocs rather than pulling the techdocs container image. This is faster on a hosted runner that already has Python.

  4. Keep the entity reference in one place. The --entity value must match the catalog entity’s namespace/kind/name exactly; a mismatch produces a green CI run but a 404 on the docs tab.

Validation

Confirm three things in order: the artifact set landed under the right prefix, its freshness marker is from this run, and the backend serves the static path. Each check verifies a different link — storage, metadata, and the reader route.

Three publish validation checks List the prefix, verify the metadata freshness timestamp, then fetch through the backend for a 200. s3 ls prefix 3 artifacts present head metadata fresh timestamp backend curl HTTP 200
Fetch through the backend, not S3 directly — that is the path real readers use and the one access control covers.
# 1. Confirm the artifact set published under the right prefix
aws s3 ls s3://${TECHDOCS_S3_BUCKET}/default/component/payments-api/
# Expected: index.html, techdocs_metadata.json, search/search_index.json

# 2. Verify the freshness marker exists and is recent
aws s3api head-object --bucket ${TECHDOCS_S3_BUCKET} \
  --key default/component/payments-api/techdocs_metadata.json \
  --query 'LastModified'
# Expected: an ISO-8601 timestamp from the current CI run

# 3. Fetch through the backend, not S3 directly
curl -s -o /dev/null -w "%{http_code}\n" \
  "http://localhost:7007/api/techdocs/static/docs/default/component/payments-api/index.html"
# Expected: 200

A successful run produces all three artifacts and a fresh techdocs_metadata.json; the backend serves a 200 on the static path.

Edge Cases & Troubleshooting

The failures here are almost all boundary mismatches: an entity reference that does not match, an IAM permission that is too narrow, or a cache that outlives the publish. The diagram sorts the symptoms into those three boundaries so you fix the right one.

Publish failures grouped by boundary Entity reference, IAM permission, and cache freshness are the three boundaries behind the common publish failures. Entity reference 404 → match namespace/kind/name docker pull → add --no-docker IAM permission AccessDenied → prefix policy + trust Cache freshness stale → no-cache + invalidate
Sort the symptom into its boundary first; each boundary has a distinct, one-line fix.
Symptom Root Cause Resolution
CI passes but the docs tab shows “Documentation not found” --entity reference does not match the catalog entity Set --entity to the exact namespace/kind/name, lowercased
AccessDenied on publish OIDC role lacks s3:PutObject on the prefix, or trust policy rejects the repo Attach the least-privilege policy above and verify the role trust condition on sub
Stale content after a successful publish CDN cached index.html with a long TTL Set no-cache on HTML and techdocs_metadata.json; invalidate the CDN path on publish
mkdocs.yml not found during generate --source-dir points above the repo root Run generate from the repo root with --source-dir .
Build pulls a Docker image unexpectedly --no-docker flag omitted Add --no-docker so the runner’s local MkDocs is used

Frequently Asked Questions

Do I need a separate CI job per service, or one shared job?

Each service repo runs its own publish job triggered on its own main pushes, so documentation updates ship with the code change that motivated them. A shared job only makes sense for a monorepo, where you iterate over entities and publish each prefix in a matrix.

Why publish to the backend’s static path instead of serving S3 directly?

Routing through the Backstage backend keeps access control, freshness checks, and the entity-aware reader consistent. Serving raw S3 bypasses RBAC and the metadata-driven cache invalidation the backend relies on.