Table of Contents
Mirrored from
DEPLOYMENT.mdat commitf31ec6a. 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 compileddist/ee/) would work at runtime, since our code compiles todist/ee/*.jsand imports core via relative paths that exist in the published image. It was rejected: it needs core's.d.tsfiles 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.ymlis 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.ymlis the same file with thedocmostservice building from./docmostinstead of pulling. -
Existing compose file you don't want to replace —
docker-compose.ee.ymldefines just the two sidecars, to layer on:docker compose -f docker-compose.yml -f apps/server/src/ee/docker-compose.ee.yml up -dAdd
GOTENBERG_URL,TIKA_URLandPDF_RENDER_URLto 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 statusis clean, so an upstream rebase has nothing of ours to conflict with. Do NOTgit applythese 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--revertto 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.