Free beta — Nibleaf Cloud is free while in beta, self-hosting is free forever.Learn more
All articles
self-hostingdockerguide

Self-Host a Documentation Site with Docker Compose

A practical walkthrough for self-hosting a documentation site with Docker Compose: server sizing, .env secrets, custom domains, backups, and upgrades.

· 6 min read · The Nibleaf team

Running your own documentation site used to mean choosing between a static site generator plus a weekend of CI plumbing, or a managed platform plus a recurring invoice. Docker Compose gives you a third path: a complete docs platform — editor, search, analytics, custom domains — on a small VPS you control. This guide walks through that setup with Nibleaf, an open-source (AGPL-3.0) documentation platform, from blank server to a backed-up, upgradeable install. The condensed reference version lives on the self-hosting page; this is the long-form walkthrough with the reasoning included.

Prerequisites

You need less than you might expect:

  • A server. Any Linux VPS or homelab box. 2 GB of RAM is plenty for a small team when you pull the prebuilt image — the whole stack idles well under that.
  • Docker Engine with the Compose v2 plugin. The one-line convenience script installs both.
  • A domain you control, if you want the docs on your own hostname. For a trial run, the server IP works fine.
curl -fsSL https://get.docker.com | sh
docker compose version   # needs the v2 plugin, not the legacy docker-compose binary

Step 1: Clone and copy the env template

You are cloning for the compose files and env template; the application itself arrives as a prebuilt image.

git clone https://github.com/lord007tn/nibleaf.git
cd nibleaf
cp .env.production.example .env

Step 2: Set your domain and secrets

Open .env and fill in the values the stack refuses to start without. Generate real secrets — the container entrypoint rejects known weak defaults in production, which is the behavior you want from software that will face the internet.

openssl rand -hex 32   # run once each for BETTER_AUTH_SECRET and the storage secret

The variables that matter on day one:

# Public origin of the dashboard (behind your reverse proxy)
APP_URL=https://docs.example.com

# Secrets — no defaults, no reuse
BETTER_AUTH_SECRET=<output of openssl rand -hex 32>
POSTGRES_PASSWORD=<strong unique password>
STORAGE_ACCESS_KEY_ID=nibleaf
STORAGE_SECRET_ACCESS_KEY=<output of openssl rand -hex 32>

# Where browsers fetch uploaded assets from
STORAGE_PUBLIC_ENDPOINT=https://storage.example.com

# Base domain for free per-project subdomains (needs a wildcard DNS record)
SITE_BASE_DOMAIN=docs.example.com

Storage is S3-compatible and pluggable: the defaults use the bundled object storage service, but pointing the STORAGE_* variables at AWS S3, Cloudflare R2, or Backblaze B2 works unchanged if you would rather keep uploads off the box.

Step 3: Bring the stack up

docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml ps

One command pulls and starts the whole platform: the dashboard app (port 4310), the API server, a background worker for publishing and search indexing, PostgreSQL 17, a Redis-compatible cache for the job queues, and the bundled S3-compatible storage service. A one-shot migrate container applies database migrations first, and the app, API, and worker wait for it to finish before starting — there is no manual migration step, on first boot or ever.

Only the app port is published, bound to 127.0.0.1 by default. Postgres, the cache, storage, the API, and the worker all stay on the internal Docker network, which is the correct default: nothing is reachable from outside until you deliberately put a proxy in front.

Step 4: Create the first account

Open http://<your-server>:4310 (or your domain once the proxy below is up) and sign up. Create your first site and you are its owner; from there you can invite teammates with owner, admin, or editor roles per site. One production default to know about: sign-in requires a verified email, so either configure mail delivery (POSTMARK_API_KEY or SMTP_URL in .env) or set REQUIRE_EMAIL_VERIFICATION=false for a private instance. And if the instance should stay private, set DISABLE_SIGNUP=true after your team is in — existing users can still sign in.

Step 5: TLS and your custom domain

Point an A record at your server, then put any TLS-terminating reverse proxy in front of port 4310. Caddy is the least ceremony:

docs.example.com {
    reverse_proxy 127.0.0.1:4310
}

That covers the dashboard. For published documentation sites, each project gets a subdomain under your SITE_BASE_DOMAIN automatically (hence the wildcard DNS record), and reader-facing custom domains — say docs.customer.com — are added per project in the dashboard, which walks you through the exact DNS records and verifies them before going live.

Wildcard subdomains need a wildcard TLS certificate, which Caddy issues via the DNS challenge — that requires a DNS-provider plugin. If that is more ceremony than you want, Coolify (below) or a Cloudflare proxy in front both sidestep it.

Where your data actually lives

Everything you would cry about losing sits in exactly two places:

DataWhere it livesBackup method
Pages, versions, users, comments, analyticsPostgreSQL (nibleaf-pg volume)pg_dump
Uploaded images and assetsS3-compatible bucket (bundled volume, or R2/S3/B2)bucket sync
Job queue and cache stateRedis-compatible cache volumedisposable — skip it

Content itself is plain Markdown in the database, not a proprietary blob format, so a database dump is also an exit hatch, not just a restore point.

Backups you can actually restore

Two commands cover both stores. Database first:

mkdir -p backups
docker compose -f docker-compose.prod.yml exec -T postgres \
  pg_dump -U nibleaf -Fc nibleaf > backups/nibleaf-$(date +%F).dump

Then the bucket. If you brought your own S3/R2/B2, use your provider's tooling; against the bundled storage the AWS CLI works because the API is S3-compatible:

aws s3 sync s3://nibleaf backups/storage/ \
  --endpoint-url https://storage.example.com

The repo ships scripts/backup.sh, which does both in one go, and the production compose file includes a commented-out pg-backup sidecar that takes rotated daily dumps automatically. Whichever you pick, put it on a schedule and ship the results off the box:

# crontab -e
0 3 * * * cd /opt/nibleaf && ./scripts/backup.sh >> /var/log/nibleaf-backup.log 2>&1

A backup you have never restored is a hypothesis. Do one test restore into a scratch database before you need it for real.

Upgrading

The image tag is pinned in .env on purpose, so upgrades are deliberate rather than whatever :latest happened to mean overnight. The flow is: back up, bump, pull, restart.

./scripts/backup.sh                                  # migrations are forward-only
sed -i 's/^NIBLEAF_VERSION=.*/NIBLEAF_VERSION=v0.2.0/' .env
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d

Migrations run automatically via the same migrate container as on first boot. Read the release notes between your version and the target — anything manual (a new required variable, say) is flagged there. Your published docs sites see at worst a brief blip during the restart window: every publish is an immutable snapshot, so readers never see a half-migrated page.

The one-click path: Coolify

If you run Coolify, you can skip most of the above: the repo ships a docker-compose.coolify.yml, so you point Coolify at the repository, set the same handful of env values in its UI, and it handles TLS certificates, domain routing, and restarts. It is the least-effort way to self-host if you already have a Coolify box humming.

When Compose is the wrong tool

Honest scoping, because self-hosting is a means, not an identity. If your docs are a handful of Markdown files maintained by developers only, a static generator like Docusaurus or MkDocs is simpler still — no server, no database, host the HTML anywhere. You give up the WYSIWYG editor and the built-in analytics, and the Nibleaf vs Docusaurus comparison walks through that trade honestly. Managed platforms like Mintlify and GitBook remove the operations work entirely, at the cost of your docs living on infrastructure you do not control — the Mintlify alternatives rundown covers when that trade makes sense.

And if you want Nibleaf without running it: the managed cloud at nibleaf.com is in free beta — pricing has the details, and signing up takes about a minute. Self-hosting stays free forever either way, with no feature gates, so you can start on cloud and move onto your own VPS later — or the other way around — without losing anything.

Frequently asked questions

How much RAM do I need to self-host a documentation site?
2 GB is enough for a small team if you pull a prebuilt image. Building the application from source inside Docker needs 5-6 GB of free RAM, so always prefer the pull-based compose file on small servers.
How do I back up a self-hosted documentation site?
Back up two things: the PostgreSQL database with pg_dump, and the object storage bucket with a sync tool such as the AWS CLI. Queue and cache state is disposable and does not need backing up.
Do I have to run database migrations manually when upgrading?
No. The production compose stack runs a one-shot migrate container that applies pending migrations before the app, API, and worker start. You only bump the version tag, pull, and restart.
Can I use my own domain with a self-hosted docs site?
Yes. Point a DNS record at your server, terminate TLS with a reverse proxy such as Caddy, and set the app URL in your .env. Nibleaf also verifies reader-facing custom domains with guided DNS instructions in the dashboard.

Ship docs your users will love

Start free on Nibleaf Cloud, or self-host the same open-source platform on your own servers.