2 Deployment
Ulysia edited this page 2026-10-06 16:54:27 +02:00

Mirrored from DEPLOYMENT.md at commit f31ec6a. Edit the file in the repository: this page is regenerated from it, and edits made here are overwritten.

Deployment

To install, follow INSTALL.md — clone, generate the wrapper Dockerfile, docker compose up -d --build. This document is the reference behind it: why the upstream commit is pinned, how the two images differ, how storage ownership works, and what CI needs.

Upstream pin

The parent repo is pinned to upstream commit 7bef7b1a (chore: package updates) — two commits past v0.96.0. Development and the server must sit on the same commit; drifting apart is what produced a wrapper Dockerfile built for Node 26 while the server ran Node 22, and a date fix written against a Mantine version the server might not have had.

git fetch origin && git checkout 7bef7b1a

Still a commit rather than a tag, but for a different reason than before. v0.96.0 does contain the Node 26 / pnpm 11 upgrade (ce8fbb86), so the original objection is gone. The two commits past it are fix: page tree reordering and a package bump, and the first matters here: /pages/move-to computes sibling positions, so it wants upstream's reordering fix rather than the release that predates it.

Worth revisiting at the next tag — the reason to sit ahead of this one is specific and will expire.

The tree stays pristine upstream — every change of ours to a parent-owned file is a patch under apps/server/src/ee/patches/, applied at build time.

Exactly one commit of ours sits on top, chore: unlink upstream EE submodule: it removes the apps/server/src/ee gitlink and .gitmodules entry and gitignores the path, which cannot be a build-time patch because it governs what git tracks at development time. It touches no source file. Anything else found committed to the parent is a mistake — see patches/README.md, "Why nothing else may be committed".

Repository layout

A deployment that pulls the published image has no clone at all:

.
├── compose.yml              yours, from ee/compose.example.yml
├── .env                     yours, from ee/.env.example
└── data/                    attachments, owned by uid 1000

Building from source is where the two repositories show up, with everything of yours still outside the clone:

.
├── compose.yml              yours, from ee/compose.dev.yml
├── .env
├── data/
└── docmost/                 fork of docmost/docmost  (upstream history kept)
    └── apps/server/src/ee/  THIS repo, gitignored by the parent
                             ssh://git@git.derg.cz/ulysia/docmost-freenterprise.git

Everything of yours sits outside the clone deliberately — config, secrets and attachments alike. The clone holds nothing you own, so it stays git status clean and an upstream move has nothing in it to conflict with.

The parent's upstream history is kept on purpose so the fork can rebase onto new Docmost tags. Upstream's own ee submodule (pointing at the private github.com/docmost/ee) was removed, along with .gitmodules.

CI assembles this the other way round, and needs no deploy key: the workflow lives in this repo, so Forgejo checks it out itself, and it then clones the public upstream and drops the bundle in. See Continuous integration below.

Two images from one Dockerfile

  • EE image — clone this repo into place, then build from source normally.
  • OSS image — don't build at all. Retag/mirror docmost/docmost:<tag>. Zero maintenance and byte-identical to upstream.

If apps/server/src/ee is simply absent, require('./ee/ee.module') fails its try/catch in app.module.ts and the app boots cleanly without any EE features — so the same Dockerfile produces a valid OSS build with no flags.

An overlay approach (FROM docmost/docmost:latest + copy a compiled dist/ee/) would work at runtime, since our code compiles to dist/ee/*.js and imports core via relative paths that exist in the published image. It was rejected: it needs core's .d.ts files at build time (may not ship in the image) and breaks silently on any upstream refactor of internal paths, with no compile-time signal.

Pin upstream by tag and bump deliberately.

Build

pnpm run editor-ext:build                  # docx-export imports @docmost/editor-ext
pnpm --filter @docmost/base-formula build  # bases imports @docmost/base-formula/server
pnpm --filter server build

Both workspace packages resolve through dist/, so a fresh clone fails with TS2307: Cannot find module '@docmost/...' until each has been built once. pnpm run build at the root runs nx run-many and orders them correctly.

Containers

INSTALL.md has a complete docker-compose.yml. The two extra services this bundle needs:

Service Image Used by
gotenberg gotenberg/gotenberg:8 PDF export
tika apache/tika:latest-full PDF/DOCX import, attachment indexing

-full is required for OCR on scanned PDFs; the slim image can't read them.

Two ways to get them, depending on what you already have:

  • New deployment — compose.example.yml is a complete compose file with all five services, pulling the published image. This is the documented path; INSTALL.md's quick start fetches it directly.

  • Building the image yourself — compose.dev.yml is the same file with the docmost service building from ./docmost instead of pulling.

  • Existing compose file you don't want to replace — docker-compose.ee.yml defines just the two sidecars, to layer on:

    docker compose -f docker-compose.yml -f apps/server/src/ee/docker-compose.ee.yml up -d
    

    Add GOTENBERG_URL, TIKA_URL and PDF_RENDER_URL to your app service yourself; the overlay cannot reach into a service it does not define.

Attachment storage

Uploads land in /app/data/storage inside the container (LOCAL_STORAGE_PATH resolves two levels up from the server's cwd), which the Dockerfile declares as a VOLUME.

The container runs as node — uid 1000, gid 1000. How that interacts with your volume depends entirely on which kind you use:

volume ownership works?
named (docmost_data:/app/data/storage) seeded from the image, already node-owned yes, nothing to do
host path (./data:/app/data/storage) whatever the host directory has only if it is uid 1000

A bind mount gets no ownership treatment at all, and Docker creates a missing source directory as root. So the common way to hit this is simply to add the volume line and up -d without creating the directory first:

mkdir -p ./data && sudo chown -R 1000:1000 ./data

uid 1000 numerically, not node — the host has no such user, and only the numbers cross the container boundary.

Diagnosing it

The symptom is every upload — images, audio, avatars, diagrams — failing with

BadRequestException: Error uploading file to drive

and a second log line carrying nothing. That blankness is core's: AttachmentService.uploadToDrive does logger.error('...', err), and Nest's Logger.error(message, stack?) reads the second argument as a stack string, so an Error object is discarded. The underlying EACCES never reaches the log. Don't read the silence as "no further information available" — there is information, it is being thrown away.

docker compose exec docmost id                       # expect uid=1000(node)
docker compose exec docmost ls -ld /app/data/storage
docker compose exec docmost sh -c 'touch /app/data/storage/.w && rm /app/data/storage/.w'

LocalDriver.upload fails three ways only: no space, not writable, or an invalid path — and the path is derived server-side, so it is one of the first two. Check df -h before ownership if the host has been near full.

Environment

The full table is in INSTALL.md. Two EE variables and one trap:

TIKA_URL=http://tika:9998             # EE env var, read in shared/ee-env.ts
PDF_RENDER_URL=http://docmost:3000    # EE env var, overrides APP_URL for Gotenberg
GOTENBERG_URL=http://gotenberg:3000   # core env var, already in .env.example

Gotenberg fetches the page over the network — it navigates Chromium to {base}/pdf-render/{pageId}?token=…, so {base} must resolve from that container. It falls back to APP_URL, which is usually wrong: a public HTTPS APP_URL sends the request out to the reverse proxy and back (hairpin NAT, often blocked) and terminates TLS for a request that never leaves the host. localhost fails outright inside compose. Set PDF_RENDER_URL to the service address.

No variable here is required to boot. Features that need one fail with a clear message (PDF export/import) or silently skip (attachment indexing).

Continuous integration

.github/workflows/build.yml builds the image on every push to main and pushes it to this instance's package registry as git.derg.cz/<owner>/<repo>:main plus a :<short-sha> tag.

.github, not .forgejo, and runs-on: ubuntu-latest, not docker — these match the known-working blazor-derg-cz workflow on the same instance. The first attempt guessed .forgejo + runs-on: docker and failed, because no runner registers that label. Copy the conventions from a workflow that already runs there rather than from the documentation.

It needs one secret, CI — a token with package write scope. The registry host and image name are derived from GITHUB_SERVER_URL and GITHUB_REPOSITORY, so nothing else is configured per instance.

No deploy key. The workflow runs in this repo, so Forgejo checks the bundle out itself; upstream Docmost is public. It clones upstream at the commit in upstream.pin — the machine-readable copy of the pin above, so CI and the docs cannot disagree — deletes the empty apps/server/src/ee gitlink that upstream tracks as a submodule, and copies the bundle in its place.

No tag trigger. Upstream is pinned to a commit rather than a tag, so a release tag here would describe only half of what the image contains. The short-SHA image tag is what you roll back to.

No layer cache

Every run rebuilds from scratch, and this image compiles the client and server from source, so runs are slow. Caching was deliberately left out: a buildx registry cache is the obvious upgrade, but a cache export the registry rejects or a buildx driver the runner cannot create fails the entire build, and none of that is knowable from here. The workflow header records the exact flags to add once the runner's capabilities are known.

External integration

Authentik / OIDC. Redirect URI: https://<app-url>/api/sso/oidc/<providerId>/callback. Create the provider via the Security settings page; the providerId is the row id. Requires the email scope — the OIDC service errors explicitly if no email claim comes back.

SCIM. Endpoint https://<app-url>/api/scim/v2, shown in-app on the SCIM settings page. Create a token there; it's displayed once. The workspace's isScimEnabled toggle must also be on — a valid token alone returns 403, so an admin can halt a sync without rotating credentials.

Client patches — applied automatically at build time

A few fixes have to touch files owned by the parent Docmost repo (the Bases client components ship with upstream). The diffs live in apps/server/src/ee/patches/, and the build applies them for you.

INSTALL.md step 2 covers running the generator and pointing compose at the result. What it does: copies your Dockerfile and inserts a patch step before the build. The patches directory sits inside the build context and is not excluded by .dockerignore, so COPY . . brings it along.

docker/Dockerfile is generated and git-ignored on purpose. An earlier version committed a hand-copied snapshot of one machine's Dockerfile, which would silently drop another machine's changes to it — local customisations (sidecars, extra packages) or new upstream build steps. Deriving it from the real file removes that whole class of problem, at the cost of one command.

Re-run the generator after editing the Dockerfile or rebasing onto a new upstream tag. The build prints a warning if it notices the source has moved on, but it will not fail over it — blocking a deploy on bookkeeping is the wrong trade. What does fail the build is a patch that will not apply.

Consequences worth knowing:

  • The working tree stays pristine. git status is clean, so an upstream rebase has nothing of ours to conflict with. Do NOT git apply these by hand for a Docker deployment.
  • A patch that fails to apply fails the build, loudly. A silently unpatched image is the failure this arrangement exists to prevent.
  • Already-applied patches are detected and skipped, so building from a tree where someone applied them manually still works.
  • Running locally (pnpm dev) needs them applied to the tree, since nothing else does it: apps/server/src/ee/scripts/apply-patches.sh (and --revert to undo).

Staying in sync with upstream's Dockerfile

Ours duplicates upstream's, so it can go stale — a new upstream build step would otherwise be dropped silently. The build checks upstream's Dockerfile against a recorded SHA-256 and fails if it changed. When that fires, diff the two files, port anything new across, and update UPSTREAM_DOCKERFILE_SHA256 in docker/Dockerfile.

See patches/README.md for what each patch fixes.