Skip to navigation

Set up self-hosted documentation

View as Markdown
Enterprise feature

This feature is available only for the Enterprise plan. To get started, reach out to support@buildwithfern.com.

Prerequisites

Before setting up self-hosted documentation, ensure you have:

  • Docker installed on your system
  • Access to your Fern project’s fern/ directory
  • A Docker Hub organization access token (OAT) from Fern (for pulling the private image)

Setup instructions

1

Authenticate with Docker Hub

The self-hosted documentation runs on a private Docker image: fernenterprise/fern-self-hosted

Contact Fern Support to receive a Docker Hub organization access token (OAT).

Log in to Docker Hub using the token provided by Fern:

docker login --username fernenterprise

When prompted for a password, enter the OAT provided by the Fern team.

In CI, pass the token via the DOCKERHUB_OAT environment variable:

echo "$DOCKERHUB_OAT" | docker login --username fernenterprise --password-stdin
2

Download the Docker image

Pull the image:

docker pull fernenterprise/fern-self-hosted:latest

Verify the image is available in your Docker daemon:

docker images | grep fernenterprise/fern-self-hosted
3

Create a Dockerfile

In the same directory that contains your fern/ folder, create a file named Dockerfile:

your-project/
├── Dockerfile
└── fern/
├── fern.config.json
├── docs.yml
└── ...

Add the following content to the Dockerfile:

Dockerfile
FROM fernenterprise/fern-self-hosted:latest
COPY fern/ /fern/
RUN fern-generate

fern-generate is a command available inside the Docker image that renders your documentation to static HTML at build time, enabling faster container startup, air-gapped deployment, and a smaller attack surface. It’s not a command you run on your host machine.

See recent releases to pin to a specific version instead of :latest.

4

Build your Docker image

From the directory containing your Dockerfile and fern/ folder, build the image:

docker build -t self-hosted-docs .
5

Run the documentation

Start your self-hosted documentation:

docker run -p 3000:3000 self-hosted-docs

The documentation will be available at localhost:3000.

6

Deploy the documentation

You can now deploy the image to your own infrastructure, allowing you to host the documentation on your own domain.

Once deployed, you can set up preview environments to preview documentation changes on every pull request.

Custom domain

Your documentation uses the domain specified in your docs.yml file. For example:

instances:
- url: example-org.docs.buildwithfern.com
custom-domain: docs.plantstore.dev

To override the domain at runtime (for example, when the actual hostname differs from the custom-domain in docs.yml), set the CUSTOM_DOMAIN environment variable:

docker run -p 3000:3000 -e CUSTOM_DOMAIN=docs.plantstore.dev self-hosted-docs

See Environment variables for details.

Environment variables

Configure the self-hosted container’s behavior by setting environment variables in your Dockerfile or Kubernetes deployment.

General

VariableDescriptionDefault
CUSTOM_DOMAINOverride the custom-domain from docs.yml at runtime. Useful when the hostname where the docs are actually served differs from the domain in docs.yml. Accepts a bare hostname (e.g., docs.plantstore.dev); any https:// or http:// prefix is stripped automatically.Value from docs.yml custom-domain
FERN_RUNTIME_ENV_VARSComma-separated list of variable names the container resolves in served content on each request. See Per-deployment values.none
FERN_LOG_LEVELLog level for the Fern CLI during docs generation. Options: debug, info, warn, error.debug
NEXT_PUBLIC_BASE_PATHOverride the base path inferred from your docs.yml sub-path, or serve from a sub-path without configuring docs.yml. The value must start with / and have no trailing slash (e.g., /docs). See Base path for details.Inferred from docs.yml sub-path (else serves from /)

Cross-origin resource sharing (CORS) proxy

The container includes a CORS proxy that allows the documentation frontend to make cross-origin requests (e.g., to your API for the API Explorer’s Try it feature). By default, only the docs domain itself is allowed. Use CORS_PROXY_ALLOWED_DOMAINS to allowlist additional domains.

VariableDescriptionDefault
CORS_PROXY_ALLOWED_DOMAINSComma-separated list of root domains to allow through the CORS proxy. Subdomains are matched automatically.none

For example, to allow requests to api.plantstore.dev and auth.plantstore.dev:

ENV CORS_PROXY_ALLOWED_DOMAINS="plantstore.dev"

To allow multiple domains:

ENV CORS_PROXY_ALLOWED_DOMAINS="plantstore.dev,partner-api.example.com"

Debugging

VariableDescriptionDefault
ENABLE_JAEGERSet to true to start Jaeger for distributed tracing. The Jaeger UI is available on port 16686.false

On-page feedback

In self-hosted mode, on-page feedback events are emitted as structured JSON logs to the container’s stdout, prefixed with [fern-docs-feedback]. You can filter for these in your logging infrastructure:

docker logs <container-id> 2>&1 | grep "\[fern-docs-feedback\]"

Each log line contains an event name, a timestamp, and a set of properties:

[fern-docs-feedback] {"event":"feedback_submitted","timestamp":"2026-01-15T12:34:56.789Z","properties":{"satisfied":true,"message":"Great docs!","email":"user@example.com","type":"on-page-feedback"}}

Tracked events

EventDescription
feedback_votedA user clicked the thumbs up or thumbs down button.
feedback_submittedA user submitted written feedback via the feedback form.
code_block_feedback_submittedA user reported an issue with a code example.

Properties

PropertyDescription
satisfiedtrue for thumbs up, false for thumbs down.
messageThe user’s written feedback message (present in feedback_submitted and code_block_feedback_submitted events).
emailThe user’s email address, if provided.
typeThe feedback source, such as on-page-feedback.

Additional configuration

The following sections cover optional configurations for specific deployment scenarios.

Base path

By default, the self-hosted container serves documentation from root (/). To serve from a sub-path like /docs, add it to your docs.yml instance and Fern picks up the base path from there, no environment variable needed.

Apply the sub-path to both the instance url and its custom-domain:

docs.yml
instances:
- url: plantstore.docs.buildwithfern.com/docs
custom-domain: docs.plantstore.dev/docs

fern generate --docs validates that an instance’s url and custom-domain share the same base path, so the sub-path must be on both or neither. With this configuration, the documentation is accessible at http://localhost:3000/docs instead of http://localhost:3000/.

Override the base path with an environment variable

Set NEXT_PUBLIC_BASE_PATH when you need a base path that differs from the docs.yml sub-path, such as reusing one prebuilt image across environments that serve from different paths. The environment variable overrides the sub-path inferred from docs.yml. The value must start with / and must not end with a trailing slash.

An image that retains the site builder can be re-rendered at startup for any base path, letting you reuse one image across environments that need different base paths.

Per-deployment values

A value that differs per deployment, such as an API hostname, can be resolved on each request instead of at build time, so one image serves every environment.

Write the value as ${VAR} with settings.substitute-env-vars, and list its name in FERN_RUNTIME_ENV_VARS at build time. Generation rewrites a listed name to a FERN_SELF_HOSTED_ENV_<NAME> placeholder instead of resolving it, and the container substitutes the placeholder from its own environment on every request:

docs.yml
settings:
substitute-env-vars: true
instances:
# Not listed, so resolved at build time.
- url: ${INSTANCE_NAME}.docs.buildwithfern.com
navbar-links:
- type: filled
text: Browse plants
url: ${PLANT_API}/plants
ENV FERN_RUNTIME_ENV_VARS=PLANT_API
RUN fern-generate
docker run -p 3000:3000 -e PLANT_API=api.plantstore.dev self-hosted-docs

The instance url and custom-domain always resolve at build time, since they’re baked into every absolute URL. Every other text artifact the container serves is substituted, including the Markdown and llms.txt versions of each page and search results. The runtime value can include a scheme (https://api.plantstore.dev) or omit it (api.plantstore.dev).

A name missing from FERN_RUNTIME_ENV_VARS, or listed with no value in the container, renders as the literal placeholder instead of an empty string.

Page content can also spell out FERN_SELF_HOSTED_ENV_<NAME> directly, for sources that don’t use ${VAR} substitution.

Air-gapped deployments with gRPC

If your API uses gRPC with dependencies from the Buf Schema Registry (BSR), the buf CLI fetches modules from buf.build during generation. This fails in air-gapped environments without network access.

1

Check for BSR dependencies

Check if your project has BSR dependencies in either location:

In buf.yaml:

version: v2
deps:
- buf.build/googleapis/googleapis
- buf.build/grpc-ecosystem/grpc-gateway

In generators.yml:

api:
specs:
- proto:
root: ./protos/
dependencies:
- buf.build/googleapis/googleapis

If there’s no deps or dependencies section (or only local paths), you can skip the rest of this section.

2

Choose a solution

If you don’t have a buf.yaml file, you can specify proto dependencies directly in your generators.yml. The self-hosted container automatically creates a temporary buf.yaml from these dependencies during the build process.

generators.yml
api:
specs:
- proto:
root: ./protos/
dependencies:
- buf.build/googleapis/googleapis
- buf.build/bufbuild/protovalidate
FROM fernenterprise/fern-self-hosted:latest
COPY fern/ /fern/
RUN fern-generate

The container parses all generators.yml files in your fern directory, finds proto specs with dependencies but no buf.yaml, and creates the necessary configuration automatically.

Kubernetes deployment

Here is a sample Deployment and Service configuration. Replace your-registry/fern-docs:latest with your image name.

Apply the configuration:

kubectl apply -f deployment.yaml
kubectl apply -f service.yaml

deployment.yaml:

deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: fern-docs
labels:
app: fern-docs
spec:
replicas: 1
selector:
matchLabels:
app: fern-docs
template:
metadata:
labels:
app: fern-docs
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
fsGroupChangePolicy: OnRootMismatch
containers:
- name: fern-docs
image: your-registry/fern-docs:latest
imagePullPolicy: IfNotPresent
ports:
- name: docs
containerPort: 3000
protocol: TCP
- name: health
containerPort: 8081
protocol: TCP
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "4Gi"
cpu: "2000m"
livenessProbe:
httpGet:
path: /liveness
port: 8081
initialDelaySeconds: 120
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /readiness
port: 8081
initialDelaySeconds: 60
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 6
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
privileged: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL
terminationGracePeriodSeconds: 30

service.yaml:

service.yaml
apiVersion: v1
kind: Service
metadata:
name: fern-docs
labels:
app: fern-docs
spec:
type: NodePort
ports:
- name: http
port: 80
targetPort: 3000
protocol: TCP
nodePort: 30080
selector:
app: fern-docs

For health check endpoint details, see Health check endpoints.