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 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

  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

# 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

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.