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.
Every storage path is relative by default
Section titled “Every storage path is relative by default”The filesystem adapters resolve their paths against the process working directory, and every default is relative:
| Option | Default |
|---|---|
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.
Put everything under one directory
Section titled “Put everything under one directory”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.
Docker
Section titled “Docker”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-slimWORKDIR /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/dataUSER node
EXPOSE 3000CMD ["node", "dist/server.js"]Then mount a volume over it:
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.
Docker Compose with Postgres
Section titled “Docker Compose with Postgres”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.
Railway and similar platforms
Section titled “Railway and similar platforms”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/dataA 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.
Check that it actually worked
Section titled “Check that it actually worked”The failure mode is silent, so verify rather than assume. Upload an image through the Studio, then force a fresh container:
docker compose up -d --force-recreate cmsReload 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.
Backups are not a substitute
Section titled “Backups are not a substitute”A volume protects you from redeploys. It does not protect you from a mistaken delete, and it is not a migration path between hosts.
trokky backup --output backup.zipproduces 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.