Backstage TechDocs Not Rendering: Fixing MkDocs Build Failures And Missing Plugin Errors
Backstage TechDocs Not Rendering: Fixing MkDocs Build Failures and Missing Plugin Errors
TechDocs is one of the most powerful features in Backstage — the idea of docs-as-code, living right next to your services in a unified developer portal, is genuinely compelling. And then you try to get it working in production, and suddenly you're staring at a blank page, a cryptic MkDocs error, or a plugin import that refuses to resolve.
I've spent more time than I'd like to admit debugging TechDocs pipelines across different organizations, and the failure modes are remarkably consistent. This guide is the definitive reference I wish had existed when I started — covering MkDocs build failures, missing plugin errors, storage backend issues, and the subtle configuration mistakes that silently break everything.
Let's get into it.
Understanding the TechDocs Architecture First
Before debugging anything, you need a clear mental model of what's actually happening when TechDocs renders documentation.
The pipeline looks like this:
Source Repository
│
▼
catalog-info.yaml (TechDocs annotation)
│
▼
TechDocs Builder (techdocs-cli or Backstage backend)
│
▼
MkDocs Build Process
├── mkdocs.yml (config)
├── docs/ directory
└── plugins (mkdocs-material, etc.)
│
▼
Static Site Output (site/)
│
▼
Storage Backend (S3, GCS, Azure Blob, or local)
│
▼
TechDocs Reader (Backstage frontend)
There are two build modes:
localmode: Backstage builds docs on-demand when a user views them. Simple to set up, terrible for production.externalmode (recommended): A CI/CD pipeline or thetechdocs-clibuilds and publishes docs to object storage. Backstage just reads from storage.
Most production issues come from one of three places: the MkDocs build itself, the plugin environment, or the storage/serving layer. We'll tackle all three.
Setting Up Your Environment Correctly
The root cause of 80% of TechDocs issues I've seen is environment inconsistency. The docs build fine on your laptop, fail in CI, and nobody understands why.
The Container Approach (Use This)
Stop fighting environment drift. Use the official techdocs-container image for all builds:
# .github/workflows/techdocs.yml
name: Publish TechDocs
on:
push:
branches: [main]
paths:
- 'docs/**'
- 'mkdocs.yml'
jobs:
publish-techdocs:
runs-on: ubuntu-latest
env:
TECHDOCS_S3_BUCKET_NAME: ${{ secrets.TECHDOCS_S3_BUCKET_NAME }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-1
ENTITY_NAMESPACE: default
ENTITY_KIND: Component
ENTITY_NAME: my-service
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Publish TechDocs
run: |
docker run \
-e AWS_ACCESS_KEY_ID \
-e AWS_SECRET_ACCESS_KEY \
-e AWS_REGION \
-v $(pwd):/content \
spotify/techdocs-container:latest \
techdocs-cli publish \
--publisher-type awsS3 \
--storage-name $TECHDOCS_S3_BUCKET_NAME \
--entity $ENTITY_NAMESPACE/$ENTITY_KIND/$ENTITY_NAME \
--directory /content
When You Can't Use the Container
If you're stuck building locally or in an environment where Docker isn't available, pin your versions explicitly:
# requirements.txt for TechDocs
mkdocs==1.5.3
mkdocs-material==9.4.14
mkdocs-techdocs-core==1.3.3
pymdown-extensions==10.5
Then install and build:
pip install -r requirements.txt
techdocs-cli generate --no-docker --source-dir . --output-dir ./site
The --no-docker flag is critical here. Without it, techdocs-cli will try to spin up the container and fail if Docker isn't available.
Diagnosing MkDocs Build Failures
Let's go through the most common failure categories with specific error messages and their fixes.
Failure 1: Config file 'mkdocs.yml' does not exist
This one is deceptively simple but bites people constantly. The error looks like:
ERROR - Config file 'mkdocs.yml' does not exist.
Causes:
- You're running the build command from the wrong directory
- The file is named
mkdocs.yaml(with ana) instead ofmkdocs.yml - The
catalog-info.yamlannotation points to the wrong path
Check your catalog-info.yaml:
# catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: my-service
annotations:
backstage.io/techdocs-ref: dir:. # This means mkdocs.yml is in the same directory
If your docs are in a subdirectory:
annotations:
backstage.io/techdocs-ref: dir:./docs-site # mkdocs.yml lives in docs-site/
The dir: prefix means "the path to the directory containing mkdocs.yml." Not the path to mkdocs.yml itself. This trips people up constantly.
Failure 2: Missing Plugin Errors
This is the most common production failure I encounter. The error looks like:
ERROR - Config value 'plugins': The "search" plugin is not installed
ERROR - Config value 'plugins': The "techdocs-core" plugin is not installed
Or the more specific import error:
ModuleNotFoundError: No module named 'mkdocs_material'
The fix depends on your build mode.
If you're using local build mode, you need to install plugins in the Backstage backend environment:
# In your Backstage backend Dockerfile
FROM node:18-bookworm-slim
# Install Python and MkDocs dependencies
RUN apt-get update && apt-get install -y python3 python3-pip && \
pip3 install mkdocs mkdocs-material mkdocs-techdocs-core --break-system-packages
# ... rest of your Backstage setup
If you're using external build mode (you should be), install them in your CI environment or use the container.
Failure 3: Plugin Version Conflicts
This one is subtle. Your mkdocs.yml specifies plugins, they're installed, but the build still fails:
ERROR - Plugin 'awesome-pages' is already installed but version 2.8.0
is required, found 2.7.0.
Or worse, no error at all but the output is broken because of incompatible plugin versions.
Here's a mkdocs.yml configuration that I've validated works reliably with the TechDocs core plugin:
# mkdocs.yml
site_name: My Service Documentation
site_description: Documentation for My Service
docs_dir: docs
site_dir: site
theme:
name: material
palette:
- scheme: default
primary: indigo
accent: indigo
features:
- navigation.tabs
- navigation.sections
- navigation.top
- search.highlight
- content.code.copy
plugins:
- techdocs-core # This MUST be first
- search
# Add additional plugins after techdocs-core
markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.tabbed:
alternate_style: true
- pymdownx.highlight:
anchor_linenums: true
- attr_list
- md_in_html
- tables
- footnotes
nav:
- Home: index.md
- Getting Started: getting-started.md
- Architecture: architecture.md
- API Reference: api-reference.md
Critical note: techdocs-core must be the first plugin listed. It sets up the base theme and configuration that other plugins depend on. Putting it second or third causes bizarre rendering issues that are nearly impossible to trace.
Failure 4: Docs Directory Structure Problems
WARNING - Documentation file 'api-reference.md' is not found at docs/api-reference.md
Your nav section in mkdocs.yml references files that don't exist. This is a warning, not an error, which means the build succeeds but pages are missing. TechDocs will serve whatever built successfully, leaving you with silent missing content.
# Quick validation script - run this before committing
#!/bin/bash
DOCS_DIR="docs"
MKDOCS_YML="mkdocs.yml"
echo "Validating nav entries against docs directory..."
# Extract nav file references and check they exist
python3 - <<EOF
import yaml
import os
import sys
with open('$MKDOCS_YML', 'r') as f:
config = yaml.safe_load(f)
nav = config.get('nav', [])
missing = []
def check_nav(nav_items, prefix=""):
for item in nav_items:
if isinstance(item, dict):
for key, value in item.items():
if isinstance(value, str):
filepath = os.path.join('$DOCS_DIR', value)
if not os.path.exists(filepath):
missing.append(filepath)
elif isinstance(value, list):
check_nav(value)
check_nav(nav)
if missing:
print("MISSING FILES:")
for f in missing:
print(f" ✗ {f}")
sys.exit(1)
else:
print("✓ All nav files exist")
EOF
The techdocs-core Plugin Deep Dive
The mkdocs-techdocs-core plugin is a meta-plugin that bundles several MkDocs plugins and extensions required for Backstage integration. Understanding what it does helps you debug when it fails.
Install it:
pip install mkdocs-techdocs-core
What it includes under the hood:
mkdocs-materialthememkdocs-monorepo-plugin(for monorepo support)pymdown-extensionsmkdocs-redirects- Various markdown extensions pre-configured
The most common issue: you install mkdocs-techdocs-core but it pulls in an incompatible version of mkdocs-material. Pin everything:
# Generate a working lockfile
pip install mkdocs-techdocs-core==1.3.3
pip freeze > requirements.txt
# Inspect what got installed
cat requirements.txt | grep -E "mkdocs|material|pymdown"
Monorepo Documentation: The Setup Nobody Documents Well
This deserves its own section because it's genuinely complex and the documentation is scattered.
If you have a monorepo with multiple services, each with their own docs, you have two approaches:
Approach 1: One catalog-info.yaml Per Service
Each service has its own catalog-info.yaml pointing to its own mkdocs.yml. This is the simplest approach and the one I recommend starting with.
monorepo/
├── services/
│ ├── auth-service/
│ │ ├── catalog-info.yaml
│ │ ├── mkdocs.yml
│ │ └── docs/
│ │ └── index.md
│ └── payment-service/
│ ├── catalog-info.yaml
│ ├── mkdocs.yml
│ └── docs/
│ └── index.md
Approach 2: Monorepo Plugin for Cross-Service Docs
When you need to aggregate docs from multiple services into one unified documentation site:
# mkdocs.yml at repo root
site_name: Platform Documentation
plugins:
- techdocs-core
- monorepo
nav:
- Home: index.md
- Auth Service: '!include ./services/auth-service/mkdocs.yml'
- Payment Service: '!include ./services/payment-service/mkdocs.yml'
The !include syntax is provided by the mkdocs-monorepo-plugin. Common failure here:
ERROR - The !include tag requires the mkdocs-monorepo-plugin to be installed.
Make sure monorepo appears in your plugins list AND that the plugin is installed. The techdocs-core plugin includes it, but if you're running an older version, install it explicitly:
pip install mkdocs-monorepo-plugin
Storage Backend Configuration
The docs built fine. Now they're not showing up in Backstage. This is a storage and serving problem, not a build problem.
S3 Configuration
# app-config.yaml
techdocs:
builder: 'external'
generator:
runIn: 'local' # or 'docker'
publisher:
type: 'awsS3'
awsS3:
bucketName: my-techdocs-bucket
region: us-east-1
# For non-default AWS auth, specify credentials
credentials:
accessKeyId: ${AWS_ACCESS_KEY_ID}
secretAccessKey: ${AWS_SECRET_ACCESS_KEY}
S3 bucket policy that actually works (the minimal version you need):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TechDocsReadWrite",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::ACCOUNT_ID:role/backstage-techdocs-role"
},
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-techdocs-bucket",
"arn:aws:s3:::my-techdocs-bucket/*"
]
}
]
}
The Entity Path Mismatch Problem
This is the most insidious TechDocs issue and affects every storage backend. The docs are published to storage, but Backstage can't find them.
TechDocs stores docs at a path derived from the entity triple: namespace/kind/name. This must match exactly what's in your catalog.
Published path:
my-techdocs-bucket/default/component/my-service/
If your catalog-info.yaml says:
metadata:
name: My-Service # Capital letters!
namespace: default
TechDocs will look for default/component/my-service (lowercased) but your CI published to default/component/My-Service. Silent failure, blank page.
Always use lowercase, kebab-case names in your catalog entities.
Verification script:
#!/bin/bash
# verify-techdocs-path.sh
# Usage: ./verify-techdocs-path.sh <bucket-name> <namespace> <kind> <name>
BUCKET=$1
NAMESPACE=$(echo $2 | tr '[:upper:]' '[:lower:]')
KIND=$(echo $3 | tr '[:upper:]' '[:lower:]')
NAME=$(echo $4 | tr '[:upper:]' '[:lower:]')
echo "Checking for TechDocs at: $NAMESPACE/$KIND/$NAME"
aws s3 ls "s3://$BUCKET/$NAMESPACE/$KIND/$NAME/" && \
echo "✓ Found TechDocs content" || \
echo "✗ No content found at expected path"
Local Development: The techdocs-cli serve Workflow
Don't push to CI to test your docs. Use techdocs-cli serve for rapid iteration:
# Install the CLI
npm install -g @techdocs/cli
# Serve docs locally with live reload
techdocs-cli serve --no-docker --port 3000
# Or with Docker (matches CI environment exactly)
techdocs-cli serve --port 3000
This spins up a local preview at http://localhost:3000 with live reload. Use this constantly.
For debugging build issues specifically:
# Verbose MkDocs output
techdocs-cli generate --no-docker --verbose
# Check what MkDocs version you're running
python3 -m mkdocs --version
# Validate mkdocs.yml without building
python3 -m mkdocs build --strict 2>&1 | head -50
The --strict flag treats warnings as errors, which is what Backstage effectively does in production. Run it locally first.
Mermaid Diagrams Not Rendering
This comes up in every TechDocs implementation. Mermaid diagrams either show as raw text or throw JavaScript errors in the browser.
The issue: Mermaid in TechDocs requires the superfences extension configured correctly, and the Backstage frontend loads Mermaid separately.
Backend: mkdocs.yml configuration
markdown_extensions:
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
In your docs:
```mermaid
graph TD
A[Client] --> B[Load Balancer]
B --> C[Service 1]
B --> D[Service 2]
```
If diagrams still don't render, check whether your Backstage instance has the Mermaid plugin installed in the frontend:
// packages/app/src/App.tsx
import { techdocsPlugin } from '@backstage/plugin-techdocs';
// In your App component, ensure TechDocs is properly configured
And in your Backstage backend package:
# Check installed packages
yarn workspace backend list | grep mermaid
# Should include @backstage/plugin-techdocs-module-addons-contrib
Common app-config.yaml Mistakes
Here's a complete, working TechDocs configuration with annotations explaining each decision:
# app-config.yaml
techdocs:
# Use 'external' in production, never 'local'
builder: 'external'
generator:
# 'docker' ensures consistent builds; 'local' is fine if you control the environment
runIn: 'docker'
# Pin the Docker image to avoid surprise breakages
dockerImage: 'spotify/techdocs-container:v1.2.4'
pullImage: true # Always pull to get security patches
publisher:
type: 'awsS3'
awsS3:
bucketName: ${TECHDOCS_S3_BUCKET_NAME}
region: ${AWS_REGION}
# This is crucial - set to true if you want Backstage to serve files
# directly rather than through the backend proxy
legacyUseCaseSensitiveTripletPaths: false
# Also ensure your backend CORS is configured for TechDocs assets
backend:
csp:
# Required for Mermaid and other dynamic content
script-src:
- "'self'"
- "'unsafe-eval'" # Needed for Mermaid
img-src:
- "'self'"
- 'data:' # Required for some diagram types
Building a Validation Pipeline
Don't let broken docs reach production. Here's a complete validation pipeline:
# .github/workflows/validate-techdocs.yml
name: Validate TechDocs
on:
pull_request:
paths:
- 'docs/**'
- 'mkdocs.yml'
- 'catalog-info.yaml'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate mkdocs.yml syntax
run: |
docker run --rm \
-v $(pwd):/content \
-w /content \
spotify/techdocs-container:latest \
python3 -c "
import yaml
import sys
try:
with open('mkdocs.yml') as f:
config = yaml.safe_load(f)
print('✓ mkdocs.yml is valid YAML')
except yaml.YAMLError as e:
print(f'✗ Invalid YAML: {e}')
sys.exit(1)
"
- name: Build TechDocs (strict mode)
run: |
docker run --rm \
-v $(pwd):/content \
-w /content \
spotify/techdocs-container:latest \
mkdocs build --strict
- name: Check for broken internal links
run: |
docker run --rm \
-v $(pwd):/content \
-w /content \
spotify/techdocs-container:latest \
bash -c "pip install linkchecker && linkchecker --check-extern site/"
- name: Verify catalog-info.yaml annotation
run: |
python3 - <<'EOF'
import yaml
import sys
with open('catalog-info.yaml') as f:
catalog = yaml.safe_load(f)
annotations = catalog.get('metadata', {}).get('annotations', {})
techdocs_ref = annotations.get('backstage.io/techdocs-ref')
if not techdocs_ref:
print('✗ Missing backstage.io/techdocs-ref annotation')
sys.exit(1)
print(f'✓ TechDocs annotation found: {techdocs_ref}')
# Validate entity name is lowercase
name = catalog.get('metadata', {}).get('name', '')
if name != name.lower():
print(f'✗ Entity name "{name}" contains uppercase - will cause path mismatch')
sys.exit(1)
print(f'✓ Entity name "{name}" is lowercase')
EOF
Debugging Checklist
When TechDocs isn't rendering, work through this list in order:
1. Verify the build is actually running
# Check CI logs for your last build
# Look for: "Successfully published techdocs for entity"
2. Verify the content exists in storage
aws s3 ls s3://my-bucket/default/component/my-service/ --recursive | head -20
# You should see index.html and other static assets
3. Verify Backstage can reach the storage backend
# Check Backstage backend logs for storage errors
kubectl logs -n backstage deployment/backstage | grep -i techdocs
4. Verify the entity name matches the storage path
# Compare catalog entity triple with storage path
# Namespace/Kind/Name must be lowercase and match exactly
5. Force a cache refresh
# TechDocs caches aggressively. In the UI, append ?refresh=true to the URL
# Or invalidate via the TechDocs API:
curl -X POST "https://backstage.example.com/api/techdocs/sync/default/component/my-service" \
-H "Authorization: Bearer $BACKSTAGE_TOKEN"
6. Check browser console for asset loading errors
TechDocs loads CSS, JS, and images dynamically. If your Backstage instance has strict CSP headers, assets may be blocked silently.
Production Recommendations
After deploying TechDocs across several platforms, here's my opinionated take on what actually works:
Use external builder always. Local builder is a load balancer killer when you have hundreds of services.
Pin your container image version in CI pipelines. Auto-updating the latest tag has broken docs deployments more times than I can count.
Set up a dedicated S3 bucket with versioning enabled. Bad docs deploys happen. Being able to roll back to a previous version without re-running CI is worth the storage cost.
Add TechDocs validation to your organization's service template in Backstage. If every new service scaffolds with a working mkdocs.yml and catalog-info.yaml, you avoid half the issues covered in this article.
Monitor storage costs. Every docs build overwrites the previous version completely. For repositories with large binary assets in docs (screenshots, diagrams), this adds up. Set up lifecycle policies.
# S3 lifecycle policy to clean up old versions
aws s3api put-bucket-lifecycle-configuration \
--bucket my-techdocs-bucket \
--lifecycle-configuration '{
"Rules": [{
"ID": "CleanupOldVersions",
"Status": "Enabled",
"NoncurrentVersionExpiration": {
"NoncurrentDays": 30
}
}]
}'
Wrapping Up
TechDocs is worth the setup pain. Having documentation live in the same developer portal as your service catalog, runbooks, and APIs genuinely changes how engineers interact with docs. The failure modes are frustrating but predictable once you understand them.
The tl;dr:
- Use the official container image for all builds
- Pin your plugin versions explicitly
techdocs-coreplugin must be first in your plugin list- Entity names in
catalog-info.yamlmust be lowercase to match storage paths - Validate your docs build with
--strictmode before pushing - External builder mode is not optional for production
If you're still stuck after working through this guide, the most useful thing you can do is run techdocs-cli generate --verbose --no-docker locally and paste the full output somewhere. The verbose output from MkDocs almost always contains the exact error — it just gets swallowed in non-verbose mode.
Was this article helpful?
Data Infrastructure Engineer
Your data is only as good as the infrastructure it sits on. I specialize in PostgreSQL, Redis, database migrations, backup strategies, and making sure your data survives whatever chaos your application throws at it.
Related Articles
ArgoCD Application Stuck In Progressing State: Debugging Sync Failures And Manual Recovery Steps
If you've worked with ArgoCD for any meaningful amount of time, you've probably stared at that dreaded "Progressing" status, watching your application sync...
ArgoCD Application Stuck In Progressing State: Diagnosing And Fixing Sync Failures
If you've worked with ArgoCD for more than five minutes, you've probably encountered this frustrating scenario: your application gets stuck in a "Progressi...
Backstage Developer Portal Setup for Platform Teams
How to set up Spotify's Backstage as a developer portal — software catalog, templates, and TechDocs for platform teams that want to scale.
Implementing Internal Developer Platform Golden Paths With Backstage Software Templates
If you've ever watched a new developer spend three days just getting a service scaffolded, configured, and deployed to a non-production environment, you un...
Crossplane: Managing Cloud Infrastructure from Kubernetes
How to use Crossplane to provision and manage cloud infrastructure using Kubernetes-native APIs — one control plane to rule them all.
Pulumi vs Terraform: An Honest Comparison from the Trenches
A real-world comparison of Pulumi and Terraform — where each shines, where each hurts, and how to pick the right one for your team.
More in Platform Engineering
View all →DevOps Team Onboarding Checklist: Get New Engineers Productive in Week One
A practical week-one DevOps onboarding checklist covering access, tooling, pipelines, and culture to get new engineers contributing fast.