Self-Hosting

Production Deployment

Production Deployment

The production stack is three containers, defined by the docker-compose.yml the installer downloaded into your install directory.

ContainerImageRole
docunexus-appdocu.nexus:<version>, recorded as APP_IMAGE in your .envAPI on 3000, collaboration WebSocket on 3001, serves the built SPA
docunexus-postgrespostgres:16-alpineDatabase. Not published to the host.
docunexus-redisredis:7.2-alpineJob queue. Not published to the host.

Four named volumes: postgres_data, redis_data, app_storage (mounted at /app/storage), and db_backups (mounted at /backups in both the app and Postgres).

Topology

flowchart LR
U[Browser] -->|HTTPS 443| N[nginx / TLS]
N -->|"root to 127.0.0.1:3000"| A[docunexus-app]
N -->|"collab to 127.0.0.1:3001"| A
A --> P[(docunexus-postgres)]
A --> R[(docunexus-redis)]
A --> S[app_storage volume]
P --> PV[postgres_data volume]

Only the app publishes ports, and only to the host. Postgres and Redis are reachable on the compose network alone.

Bring it up

The installer already did this. You are reading this section either because you stopped the stack, or because you want to know what it did.

cd docu.nexus
docker compose up -d
docker compose logs -f app

The entrypoint waits for Postgres, runs migrations, then starts the server. See Upgrading for the detail.

To change configuration first, edit .env and then run the same two commands — Compose recreates any container whose environment changed.

$EDITOR .env
docker compose up -d

Behind nginx

The app speaks plain HTTP. Terminate TLS at the proxy. The parts that matter:

client_max_body_size 100M;

location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Upgrade           $http_upgrade;
    proxy_set_header Connection        "upgrade";
    proxy_read_timeout 86400;
}

location /collab/ {
    proxy_pass http://127.0.0.1:3001/;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 86400;
}

The /collab/ block is not optional. Over HTTPS the editor connects to wss://<your-host>/collab by default. Without that location, real-time editing silently fails to connect and everyone edits alone.

Routing /collab to the collaboration port is what matters; stripping the prefix is not. The trailing slash on proxy_pass http://127.0.0.1:3001/; removes /collab from the path, which is tidy but optional: the collaboration server reads only the query string of the request and never the path. A proxy that cannot rewrite paths works as well.

The two ports are the host ports from your .env: HOST_PORT (default 3000) for everything, and HOST_COLLAB_PORT (default 3001) for /collab. If something else on the host already holds either, set both in .env, run docker compose up -d, and point the proxy at the new values. Inside the container the app always listens on 3000 and 3001.

Raise client_max_body_size if you expect uploads larger than 100 MB.

With no reverse proxy: Cloudflare Tunnel

If you have a domain and a machine but no proxy and no certificates, a Cloudflare Tunnel running on the host does the same job outbound-only: no port forwarding, no static IP, no certificate handling, and it creates the DNS record itself. The ingress needs the same two routes, collaboration first:

ingress:
  - hostname: docs.example.com
    path: ^/collab
    service: http://localhost:3001
  - hostname: docs.example.com
    service: http://localhost:3000
  - service: http_status:404

Use your HOST_PORT and HOST_COLLAB_PORT values if you changed them. The tunnel cannot strip the /collab prefix, and it does not need to. TLS ends at Cloudflare, so set APP_URL to the https:// origin and leave COOKIE_SECURE unset.

Hardening

Do all six before anyone else logs in.

  1. Use a real signing secret. The installer generated a JWT_SECRET for you. Check that .env has it, and that it is not the literal change-me-in-production, which is what the code falls back to when the variable is unset. Anyone who knows that string can mint a session. To make your own: openssl rand -hex 32.

  2. Use a real database password. DB_PASSWORD is also generated by the installer. The compose file builds DATABASE_URL from it, so setting it once covers both containers. Do not change it after the first start: the password is baked into the database when Postgres initialises, so a new value in .env changes what the application sends and not what the database expects, and the app crash-loops on authentication failure.

  3. Get the app URL right. APP_URL is the full origin, protocol included, no trailing slash. OAuth callbacks and CORS are derived from it. CORS_ORIGIN falls back to APP_URL when unset, which is what you want. Set CORS_ORIGIN separately only if the API and the UI live on different origins.

  4. Put TLS in front, or turn the secure cookie off. Session cookies are marked Secure in production, and a browser will not store a Secure cookie that arrived over plain http. Sign-in then returns 200 and every request after it is a 401, which reads as broken authentication rather than as configuration. The installer writes COOKIE_SECURE=false for you when you gave it an http:// URL. Remove that line once TLS is in front, and update APP_URL to the https:// origin at the same time.

    The cookie is not the only thing you give up. Secure is one of several capabilities a browser grants only to a secure context, and screen capture is another: on a plain http origin the screen-capture API is not merely refused, it is absent, so Send feedback cannot attach a picture of your instance and offers an upload button instead. localhost and 127.0.0.1 count as secure, so this shows up when people reach the instance by IP address or an internal hostname, and not when you are testing on the box itself. TLS is not a guarantee of the checkbox, only a precondition for it: no mobile browser implements screen capture on any origin, so phones and tablets get the upload button whatever you put in front of the instance.

  5. Leave registration off. Setting ALLOW_REGISTRATION at all pins the answer at deployment level and makes the admin panel show registration read-only: the exact string true allows self-signup, any other value refuses it. Leave it unset and the stored instance policy decides instead, which is the Let anyone create their own account checkbox on the Email and password card in Admin, then Authentication, then Sign-in methods. That policy is off until someone turns it on, so an untouched instance is closed either way. Pin it with ALLOW_REGISTRATION=false when you want to take the decision away from admins entirely.

  6. Do not try to turn seeding on. docker-compose.yml pins SEED_ENABLED: "false" in its environment: block, which outranks .env, so writing SEED_ENABLED=true there does nothing at all. That is deliberate: the bootstrap seed creates an account whose password is published. There is no default account and no default password on your instance — the first administrator is the one you created on the setup screen.