Skip to content

Docker and persistent storage

Trokky writes to the filesystem. If that filesystem does not survive a restart, neither does your content. Deployment shapes lists exactly what is destroyed; this page is how to stop it happening.

The filesystem adapters resolve their paths against the process working directory, and every default is relative:

OptionDefault
contentDir./content
usersDir./users
tokensDir./tokens
webhooksDir./webhooks
settingsDir./settings
auditLogsDir./audit-logs
mediaDir./media

Two consequences follow, and both cause real incidents.

The defaults scatter seven sibling directories across your working directory. Mounting a volume at any one of them protects that one and silently leaves the rest ephemeral. A deploy then keeps your media and loses every user account, which looks like a corrupted install rather than a storage problem.

And because they are relative, the same config resolves differently depending on where the process was started from. A container that runs node dist/server.js from /app writes to /app/content. Change the working directory and you have quietly pointed the CMS at a different, empty store — the CMS starts fine and reports no content.

Give every path an explicit home under a single parent. One directory to mount, one thing to back up:

storage: {
data: {
adapter: 'filesystem-data',
options: {
contentDir: './data/content',
usersDir: './data/users',
tokensDir: './data/tokens',
webhooksDir: './data/webhooks',
settingsDir: './data/settings',
auditLogsDir: './data/audit-logs',
},
},
media: {
adapter: 'filesystem-media',
options: { mediaDir: './data/media' },
},
}

Now ./data is the only thing that has to survive.

Keep the paths relative rather than absolute. The same config then works in development, in a container, and on a platform that mounts volumes wherever it likes — as long as the process starts from the right directory.

The application is an ordinary Node server, so the Dockerfile is unremarkable. What matters is that WORKDIR is where the relative paths resolve, and that /app/data is a mount point rather than a layer in the image.

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
# Created here so the directory exists with the right owner before a volume
# is mounted over it.
RUN mkdir -p /app/data && chown -R node:node /app/data
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Then mount a volume over it:

Terminal window
docker volume create trokky-data
docker run -d \
-p 3000:3000 \
-v trokky-data:/app/data \
-e TROKKY_JWT_SECRET="$(openssl rand -base64 32)" \
-e NODE_ENV=production \
my-site

-v trokky-data:/app/data is the whole point. Without it the container writes into its own writable layer, which is discarded when the container is replaced — including by a routine docker compose up after a rebuild.

Do not add data/ to the image. Put it in .dockerignore alongside node_modules, so a stale local copy is never baked into a layer and then shadowed by the mount.

For anything beyond a single editor, keep documents in Postgres and only media on disk. Media still needs the volume: there is no media adapter that does not write files.

services:
cms:
build: .
ports:
- '3000:3000'
environment:
NODE_ENV: production
DATABASE_URL: postgres://trokky:${POSTGRES_PASSWORD}@db:5432/trokky
TROKKY_JWT_SECRET: ${TROKKY_JWT_SECRET}
volumes:
- media:/app/data/media
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_USER: trokky
POSTGRES_DB: trokky
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U trokky']
interval: 5s
retries: 10
volumes:
media:
pgdata:

With the matching config:

storage: {
data: {
adapter: 'postgres-data',
options: { connection: process.env.DATABASE_URL },
},
media: {
adapter: 'filesystem-media',
options: { mediaDir: './data/media' },
},
}

Two things people miss here. pgdata matters as much as media — without it, postgres:16 keeps its database inside the container and a rebuild drops every document. And TROKKY_JWT_SECRET must be set: unset, Trokky generates a random one at boot and every session dies on the next restart. See Traps.

Platforms that build from your repository generally give you one volume and ask where to mount it.

Mount it at the directory your paths resolve to. With a Nixpacks build the working directory is /app, so mediaDir: './data/media' resolves to /app/data/media, and the mount point is:

/app/data

A railway.json for the build and start side:

{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "NIXPACKS",
"buildCommand": "npm install && npm run build"
},
"deploy": {
"startCommand": "npm start",
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10
}
}

Volumes are per-environment. A staging environment needs its own, and it starts empty — staging showing no content is usually a missing volume rather than a broken deploy.

If your frontend is built in the same step, remember that build-time environment variables have to exist during the build command, not just at runtime. That one has its own entry in Traps.

The failure mode is silent, so verify rather than assume. Upload an image through the Studio, then force a fresh container:

Terminal window
docker compose up -d --force-recreate cms

Reload the document. If the image still renders, the volume is doing its job. If it 404s while the document still references it, the media went into the container layer — the mount path and your mediaDir do not agree.

Do the same check after your first real deploy, before there is anything in there worth losing.

A volume protects you from redeploys. It does not protect you from a mistaken delete, and it is not a migration path between hosts.

Terminal window
trokky backup --output backup.zip

produces a portable archive of documents and media that restores into a different storage backend entirely — Postgres to filesystem and back. Backup and restore covers what it preserves, and what it renumbers.