> For clean Markdown content of this page, append .md to this URL. For the complete documentation index, see https://buildwithfern.com/learn/llms.txt. # Set up self-hosted documentation > Learn how to set up self-hosted documentation on your own infrastructure. #### Enterprise feature This feature is available only for the [Enterprise plan](https://buildwithfern.com/pricing). To get started, reach out to [support@buildwithfern.com](mailto: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 #### 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: ```bash 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: ```bash echo "$DOCKERHUB_OAT" | docker login --username fernenterprise --password-stdin ``` #### Download the Docker image Pull the image: ```bash docker pull fernenterprise/fern-self-hosted:latest ``` Verify the image is available in your Docker daemon: ```bash docker images | grep fernenterprise/fern-self-hosted ``` #### 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`** ```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](/learn/docs/self-hosted/release-notes) to pin to a specific version instead of `:latest`. #### Build your Docker image From the directory containing your `Dockerfile` and `fern/` folder, build the image: ```bash docker build -t self-hosted-docs . ``` #### Run the documentation Start your self-hosted documentation: ```bash docker run -p 3000:3000 self-hosted-docs ``` The documentation will be available at [localhost:3000](http://localhost:3000). #### 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](/learn/docs/self-hosted/previews) to preview documentation changes on every pull request. ## Custom domain Your documentation uses the domain specified in your `docs.yml` file. For example: ```yaml 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: ```bash docker run -p 3000:3000 -e CUSTOM_DOMAIN=docs.plantstore.dev self-hosted-docs ``` See [Environment variables](#environment-variables) for details. ## Environment variables Configure the self-hosted container's behavior by setting environment variables in your Dockerfile or Kubernetes deployment. ### General | Variable | Description | Default | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | `CUSTOM_DOMAIN` | Override 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_VARS` | Comma-separated list of variable names the container resolves in served content on each request. See [Per-deployment values](#per-deployment-values). | none | | `FERN_LOG_LEVEL` | Log level for the Fern CLI during docs generation. Options: `debug`, `info`, `warn`, `error`. | `debug` | | `NEXT_PUBLIC_BASE_PATH` | Override 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](#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. | Variable | Description | Default | | ---------------------------- | ----------------------------------------------------------------------------------------------------------- | ------- | | `CORS_PROXY_ALLOWED_DOMAINS` | Comma-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`: ```dockerfile ENV CORS_PROXY_ALLOWED_DOMAINS="plantstore.dev" ``` To allow multiple domains: ```dockerfile ENV CORS_PROXY_ALLOWED_DOMAINS="plantstore.dev,partner-api.example.com" ``` ### Debugging | Variable | Description | Default | | --------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------- | | `ENABLE_JAEGER` | Set to `true` to start [Jaeger](https://www.jaegertracing.io/) for distributed tracing. The Jaeger UI is available on port 16686. | `false` | ## On-page feedback In self-hosted mode, [on-page feedback](/learn/docs/user-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: ```bash docker logs 2>&1 | grep "\[fern-docs-feedback\]" ``` Each log line contains an event name, a timestamp, and a set of properties: ```json [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 | Event | Description | | ------------------------------- | -------------------------------------------------------- | | `feedback_voted` | A user clicked the thumbs up or thumbs down button. | | `feedback_submitted` | A user submitted written feedback via the feedback form. | | `code_block_feedback_submitted` | A user reported an issue with a code example. | ### Properties | Property | Description | | ----------- | ----------------------------------------------------------------------------------------------------------------- | | `satisfied` | `true` for thumbs up, `false` for thumbs down. | | `message` | The user's written feedback message (present in `feedback_submitted` and `code_block_feedback_submitted` events). | | `email` | The user's email address, if provided. | | `type` | The 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`** ```yaml 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. #### Build-time (recommended) Set `NEXT_PUBLIC_BASE_PATH` in your Dockerfile before running `fern-generate`. The site is rendered with that base path at build time, so the container can run with a read-only filesystem. ```dockerfile FROM fernenterprise/fern-self-hosted:latest COPY fern/ /fern/ ENV NEXT_PUBLIC_BASE_PATH=/docs RUN fern-generate ``` #### Runtime Pass `NEXT_PUBLIC_BASE_PATH` when starting the container. ```bash docker run -p 3000:3000 -e NEXT_PUBLIC_BASE_PATH=/docs self-hosted-docs ``` The base path is compiled into every URL of the static site, so a runtime base path that differs from the one the image was built with re-renders the site at startup. Re-rendering writes into the container filesystem, so it's not compatible with `readOnlyRootFilesystem: true` in Kubernetes, and it requires the image to retain the site builder: build with `FERN_KEEP_BUILD_TOOLS=1`. 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`](/learn/docs/configuration/site-level-settings#settingssubstitute-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_` placeholder instead of resolving it, and the container substitutes the placeholder from its own environment on every request: **`docs.yml`** ```yaml 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 ``` ```dockerfile ENV FERN_RUNTIME_ENV_VARS=PLANT_API RUN fern-generate ``` ```bash 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](/learn/docs/ai-features/markdown) and [`llms.txt`](/learn/docs/ai-features/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_` 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](https://buf.build) (BSR), the `buf` CLI fetches modules from `buf.build` during generation. This fails in air-gapped environments without network access. #### Check for BSR dependencies Check if your project has BSR dependencies in either location: **In `buf.yaml`:** ```yaml version: v2 deps: - buf.build/googleapis/googleapis - buf.build/grpc-ecosystem/grpc-gateway ``` **In `generators.yml`:** ```yaml 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. #### Choose a solution #### Option 1: Build-time generation (recommended) Run `fern-generate` at build time when network access is available: ```dockerfile FROM fernenterprise/fern-self-hosted:latest COPY fern/ /fern/ RUN fern-generate ``` This downloads BSR dependencies during the Docker build and bakes them into the image. No network access required at runtime. #### Option 2: Specify dependencies in generators.yml 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`** ```yaml generators.yml api: specs: - proto: root: ./protos/ dependencies: - buf.build/googleapis/googleapis - buf.build/bufbuild/protovalidate ``` ```dockerfile 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: ```bash kubectl apply -f deployment.yaml kubectl apply -f service.yaml ``` **deployment.yaml:** **`deployment.yaml`** ```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`** ```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](/learn/docs/self-hosted/health-check-endpoints). > Learn how to set up self-hosted documentation on your own infrastructure.