Get Started for FREE

Building a Production-Ready Collaboration Backend for Froala

collaboration backend for Froala

Two people or more, one document, no lost edits. Froala 5.3 solve the hardest part of real‑time collaboration. It ships Yjs in the browser to merge concurrent keystrokes into a single coherent document, so you don’t need to wrestle with CRDTs or conflict math.

What is left for you is a small, reliable server: a WebSocket relay to pass updates between peers and a persistence layer to keep comments, versions, and the canonical copy. Neither one is exotic. This guide shows how to build those two ordinary pieces with Froala’s Node.js SDK and how to avoid the common pitfalls that turn a demo into a maintenance problem.

The Froala Collaboration Backend Server’s Function

The collaboration backend server has two responsibilities:

Passing messages between browsers

A WebSocket is a connection that stays open in both directions. Froala’s relay groups those connections by docId, which is simply the room number where collaborators meet. When one browser sends an update, the relay rebroadcasts it verbatim to every other browser in the same room. It never parses the message, never validates it, never stores it. All the intelligence is in the clients.

Persistence

The relay forgets everything the moment the last person closes their tab. So comments, track-change suggestions, and version snapshots travel over a second path: plain REST endpoints backed by SQLite. That’s what survives an empty room, a browser refresh, and a server restart.

The Froala Node.js SDK ships these as four modules you compose as needed.

Module What it handles Transport
Collaborative Live peer sync, presence, cursors WebSocket
CollabPersistence Suggestions and comments REST + SQLite
VersionControl Named and automatic snapshots REST + SQLite
AsyncSave Latest document content in offline mode REST + SQLite

collaboration server components

Why this matter? If you mount only the relay, everything works beautifully until the last person closes the tab, and then it is gone. Real-time sync and persistence are two separate responsibilities, and you need both.

Real-time or asynchronous: pick one first

The Froala Collaborative plugin runs in two modes, and which one you are in is decided by a single value. Setting collabConfig.realTime.syncUrl turns on live sync across every collaborative feature. Leaving it out drops the plugin into asynchronous, offline-first mode where the REST endpoints are the only source of truth. Decide this before you write the server, because it changes what you have to mount.

Real-time Asynchronous
Set realTime.syncUrl plus the REST URLs REST URLs only
Needs All four modules The three REST modules
Feels like Cursors and edits appear instantly Changes appear on load or on refresh
Suits Live co-editing sessions Review cycles, flaky networks, simpler infrastructure

Prerequisites for the Froala Collaboration Backend

You need:

  • Node.js 18 or newer
  • npm

1. Install the SDK

mkdir froala-collab && cd froala-collab
npm init -y
npm install express wysiwyg-editor-node-sdk

That is everything the backend needs to run. The SQLite driver, better-sqlite3, is a regular dependency of the SDK and installed automatically. It is a native module, so on a bare Linux container without a prebuilt binary available you may also need build tools (python3, make, g++) present during install. Two small packages for CORS and rate limiting appear later, once we get to hardening.

2. Create the folder your database lives in

SQLite will create the database file for you. It will not create the directory that file goes in.

Common first error. Pointing dbPath at data/collab.db when data/ does not exist throws SQLITE_CANTOPEN on startup. The fix is one line in the server, shown next, so the folder is always there before any module opens the file.

3. Write the server

Create server.js and build it up in pieces.

const http    = require('http');
const path    = require('path');
const fs      = require('fs');
const express = require('express');

// Pull in only the collaboration modules you plan to mount.
const {
  Collaborative,      // WebSocket relay
  CollabPersistence,  // suggestions + comments
  VersionControl,     // version snapshots
  AsyncSave,          // offline content save/load
} = require('wysiwyg-editor-node-sdk');

Next, guarantee the database folder exists, then point every module at one shared file.

const dataDir = path.join(__dirname, 'data');
// recursive:true creates the folder if missing and stays quiet if it exists.
fs.mkdirSync(dataDir, { recursive: true });

const dbPath = path.join(dataDir, 'collab.db');

Now the Express app. The body limit deserves a moment of attention.

const app = express();

// Version snapshots are entire HTML documents, not small form posts.
// Express defaults to 100kb and answers 413 above that, which shows up
// in the browser as "saving a version silently does nothing".
app.use(express.json({ limit: '5mb' }));

Now the piece that catches nearly everyone on the first run. Your editor page is almost certainly served from a different origin than port 3000, and a browser will block every REST call until you say otherwise.

// npm install cors
const cors = require('cors');

// Name the exact origin your page is served from. Not '*', because
// credentials:true and a wildcard origin are mutually exclusive.
app.use(cors({
  origin: process.env.APP_ORIGIN,   // e.g. http://localhost:5173
  credentials: true,                // lets session cookies ride along
}));

What this looks like when it is missing. The editor loads, cursors sync fine over the WebSocket, and every comment request fails with a CORS message in the console. The relay is unaffected because WebSockets are not subject to the same-origin policy, which is exactly why the failure looks so selective.

// Each call registers its REST routes and creates its tables on first run.
// Sharing one dbPath keeps everything in a single file.
CollabPersistence.attachRoutes(app, { dbPath });
VersionControl.attachRoutes(app, { dbPath });
AsyncSave.attachRoutes(app, { dbPath });

// getStats() reports live rooms and connections. Useful for monitoring.
app.get('/health', (req, res) => res.json(Collaborative.getStats()));

Finally, put the relay and the REST API on the same port.

// Build the HTTP server yourself so both can share it.
const server = http.createServer(app);

// This line tells Froala to listen for WebSocket upgrades on our server.
Collaborative.attachToServer(server);

server.listen(3000, () => {
  console.log('Collaborative backend on http://localhost:3000');
});

attachToServer or createServer? Use attachToServer() when REST and the relay share a port, which is almost always. createServer() is for running the relay alone on its own port, with nothing else on it.

4. Run it and confirm it is alive

node server.js

In a second terminal:

curl http://localhost:3000/health

// {"docs":0,"clients":0}

curl http://localhost:3000/collab/my-doc/comments

// []

An empty array is a success. It means the route is mounted, the database opened, and the table exists. Notice the URL shape: every REST endpoint is namespaced under /collab/:docId/, and that docId has to match the one the WebSocket connection uses.

5. Put the editor on the page

The backend is only half the system. Load the packaged build, which ships every plugin including the collaborative one.

<link rel="stylesheet" href="/node_modules/froala-editor/css/froala_editor.pkgd.min.css">
<link rel="stylesheet" href="/node_modules/froala-editor/css/froala_style.min.css">

<div id="editor"></div>

<script src="/node_modules/froala-editor/js/froala_editor.pkgd.min.js"></script>
<script>
  const editor = new FroalaEditor('#editor', {
    // Read the key from injected config. 
    key: window.APP_CONFIG.froalaKey,

    // collabConfig arrives in the next step.
  });
</script>

If you are using a bundler and a modular build instead, you have to import the collaborative plugin’s JavaScript and its stylesheet by their exact paths under node_modules/froala-editor, because the core alone does not include it.

6. Tell the editor these endpoints exist

This is the step that trips up almost everybody. Mounting routes on your server does not make the editor call them. The editor only talks to URLs you hand it in collabConfig.

new FroalaEditor('#editor', {
  key: window.APP_CONFIG.froalaKey,   

  collabConfig: {
    // The room number. Same value as the WebSocket path.
    docId: 'my-doc',

    // Display information only. The server decides who this really is.
    user: { id: 'user-42', name: 'Priya', role: 'editor' },

    // Persistence. Without these three, comments, suggestions and
    // versions live only for the duration of the session.
    commentsUrl:    'http://localhost:3000/collab/my-doc/comments',
    suggestionsUrl: 'http://localhost:3000/collab/my-doc/suggestions',
    versionControl: { url: 'http://localhost:3000/collab/my-doc/versions' },

    // The live wire. Omit this object entirely for offline-first mode.
    realTime: { syncUrl: 'ws://localhost:3000' }
  },

  // Async mode stores the document itself through AsyncSave.
  saveURL: 'http://localhost:3000/collab/my-doc/content'
});

Two things worth internalising. First, realTime.syncUrl is the master switch: set it and everything synchronises live, leave it out and the plugin falls back to asynchronous offline-first mode using the REST URLs alone. Second, in real-time mode those REST URLs are still doing work. Live sync shares data between connected peers, but it is the URLs that write it to disk and fetch it back on the next page load.

Do not hardcode the document id

The configuration above repeats my-doc in four places plus the WebSocket path. In a real application that is five chances to typo a room number, and a mismatch between the REST docId and the WebSocket docId produces a document that syncs live but persists into a different bucket. Build the whole object from one value instead.

function collabConfigFor(docId, user) {
  const origin = window.APP_CONFIG.collabOrigin;     // https://collab.example.com
  const base   = `${origin}/collab/${encodeURIComponent(docId)}`;

  return {
    docId,
    user,                                            // display information only

    commentsUrl:    `${base}/comments`,
    suggestionsUrl: `${base}/suggestions`,

    versionControl: {
      url: `${base}/versions`,
      autoSaveEnabled: true,
      // Default is 60000. Every interval writes a full copy of the
      // document, so five minutes is kinder to your disk and to the
      // retention job later in this guide.
      autoSaveInterval: 300000,
    },

    realTime: {
      // http becomes ws, https becomes wss, without a second constant.
      syncUrl: origin.replace(/^http/, 'ws'),
      reconnectDelay: 2000,
    },
  };
}
new FroalaEditor('#editor', {
  key: window.APP_CONFIG.froalaKey,
  htmlExecuteScripts: false,
  collabConfig: collabConfigFor('q3-report', currentUser),
  saveURL: `${origin}/collab/q3-report/content`,
});

7. Load the document’s starting content

Here’s the consequence of the relay being amnesiac: when the first person opens a document, there’s no peer to sync from. The editor starts empty. Nothing in the SDK fills that gap. The starting content has to come out of your storage and into the element before the editor initializes.

In asynchronous mode, and in real-time mode where you also persist through AsyncSave, that content is one request away.

async function startingHtml(docId) {
  const res = await fetch(`/collab/${encodeURIComponent(docId)}/content`, {
    credentials: 'include',
  });

  // 404 is the normal answer for a document nobody has saved yet.
  if (res.status === 404) return '';

  const { content } = await res.json();
  return content;
}

const docId = 'q3-report';

// Seed the element first, then let Froala take over the filled element.
document.querySelector('#editor').innerHTML = await startingHtml(docId);

new FroalaEditor('#editor', {
  key: window.APP_CONFIG.froalaKey,
  htmlExecuteScripts: false,
  collabConfig: collabConfigFor(docId, currentUser),
  saveURL: `${window.APP_CONFIG.collabOrigin}/collab/${docId}/content`,
});

Every client seeds from the same stored row, so peers start from identical state and live updates apply on top of it. Server-rendering the same HTML into the element works just as well and avoids the extra round trip. What does not work is seeding different clients with different content, because each one then contributes its own starting text to the shared document.

Test the stale case on purpose. Open a document, make changes, and open it again in a second browser before any save has happened. That is the scenario where the stored row and the live document disagree, and it is worth knowing how your application behaves there before a user finds it.

8. Show people the connection state

A WebSocket that drops does so silently. The editor keeps accepting keystrokes, the cursor keeps blinking, and nothing suggests that the last four paragraphs are going nowhere. Three events exist precisely so that you do not ship that experience.

new FroalaEditor('#editor', {
  // ...
  events: {
    // Fires on every transition: connecting, connected, disconnected.
    'collab.connectionStatus': function (status) {
      const banner = document.querySelector('#collab-banner');
      banner.hidden = status === 'connected';
      banner.textContent = status === 'connecting'
        ? 'Reconnecting to the collaboration server'
        : 'Offline. Your changes are not being shared right now.';
    },

    // The plugin has finished initialising. Safe to edit or to run
    // data operations from here on.
    'collab.synced': function () {
      document.querySelector('#collab-banner').hidden = true;
    },

    // Someone else's change just landed in your content.
    'collab.remoteContentChanged': function () {
      markUnsaved();
    },
  },
});

The editor’s own toast notifications are a lighter-touch alternative to a banner, and a persistent one is well suited to a state that resolves on its own.

const connecting = editor.toast.show({
  title: 'Reconnecting',
  description: 'Waiting for the collaboration server.',
  interval: false,        // stays until you dismiss it
});

// Once 'collab.connectionStatus' reports connected:
connecting.hide();

Retry timing is yours to set. realTime.reconnectDelay is the wait in milliseconds before the client tries again after a drop, defaulting to two seconds. Raising it eases load on a server that is struggling; lowering it makes brief network blips less visible.

9. Test it properly

Start the backend first, then open your page in two windows: one normal, one private or a different browser. Two tabs in the same window share a session, so you will not see two distinct users. Type in one window and watch the other. A caret with a name label should appear within a second.

Then do the harder test: close both windows, reopen one, and confirm your comments and versions came back. That is the test that proves the database half is wired up.

Make Your Froala Collaboration Backend Production Ready

Everything above runs happily on localhost. Here is what has to change before it faces the internet.

production collaboration server

Identity comes from your session, not from the client

The user object in collabConfig is display information that any visitor can edit in devtools. Treat id, name, and especially role as hints for rendering cursors and nothing more. Authenticate the WebSocket upgrade request and every REST call, then derive the real user and the real role from your session on the server. Authorization is per document: whether this person may join this docId is a server decision, never a client claim. Verify the Origin header on upgrades too, since browsers do not apply the same-origin policy to WebSockets the way they do to fetch.

// server.js · replaces attachToServer

const { WebSocketServer } = require('ws');

// Manage the ws instance yourself so nothing is wired up before you have
// checked who is calling. With this in place, drop attachToServer().
const wss = new WebSocketServer({ noServer: true });

server.on('upgrade', async (req, socket, head) => {
  // 1. Browsers do not enforce same-origin on WebSockets. You do.
  if (req.headers.origin !== process.env.APP_ORIGIN) return socket.destroy();

  // 2. Real identity comes from your session store, not from the page.
  const session = await getSession(req.headers.cookie);
  if (!session) return socket.destroy();

  // 3. The room number lives in the URL path, so it is a claim. Check it.
  const docId = decodeURIComponent(req.url.split('?')[0].slice(1));
  if (!(await canOpenDoc(session.userId, docId))) return socket.destroy();

  // Approved. Now hand the connection to Froala's relay.
  wss.handleUpgrade(req, socket, head, (conn) => {
    Collaborative.setupWSConnection(conn, req);
  });
});

The REST side needs the same two checks. Register the guard before your attachRoutes calls so that it runs first.

// server.js · register before attachRoutes

app.use('/collab/:docId', async (req, res, next) => {
  const session = await getSession(req.headers.cookie);
  if (!session) return res.status(401).json({ error: 'not signed in' });

  if (!(await canOpenDoc(session.userId, req.params.docId))) {
    return res.status(403).json({ error: 'no access to this document' });
  }

  // Suggesting versus editing is a server decision. collabConfig.user.role
  // only decides which buttons the browser draws.
  if (req.method !== 'GET' && session.role === 'viewer') {
    return res.status(403).json({ error: 'read only' });
  }

  req.user = session;   // the identity the rest of your code trusts
  next();
});

Let the server decide the role

Roles are the part of this that looks like authorization but is not. collabConfig.user.role takes one of three values, and each one controls which modes the editor will let a person switch into.

Role Modes allowed Starts in
editor Editing, suggesting, viewing Editing
suggester Suggesting, viewing Suggesting
viewer Viewing only Viewing

That gate lives in the browser, so it decides which buttons are drawn and nothing more. Someone who edits the value in devtools gets an editing toolbar. What stops them writing is the REST guard above, which is why the role has to make a round trip: your server decides it, injects it, and then enforces it independently.

// Server: send the role down with the page, alongside the key.
res.render('document', {
  appConfig: {
    froalaKey: process.env.FROALA_KEY,
    collabOrigin: process.env.COLLAB_ORIGIN,
    user: { id: session.userId, name: session.name, role: roleFor(session, docId) },
  },
});
// Client: react to mode changes so your own UI stays in step.
events: {
  'collab.modeChanged': function (newMode, prevMode) {
    document.body.dataset.collabMode = newMode;
    saveButton.disabled = newMode !== 'editing';
  },
},

// Switching programmatically is a no-op when the role forbids it,
// so this is safe to call without checking the role first.
editor.collaborative.setMode('suggesting');

Sanitize the HTML, twice

Version snapshots and async content are raw editor HTML, and the content endpoint returns it verbatim. Sanitization is your responsibility. Run a maintained server-side sanitizer with an allow-list of tags and attributes before you store the content, and again before you render it anywhere. The editor’s client-side options like htmlAllowedTags shape editing quality; they run in the browser and are not a security boundary.

//server.js · register before attachRoutes

const sanitizeHtml = require('sanitize-html');

const POLICY = {
  // Match this list to what your toolbar can actually produce.
  // Stricter than the editor is fine. Looser is a hole.
  allowedTags: ['p', 'br', 'strong', 'em', 'u', 's', 'h1', 'h2', 'h3',
                'ul', 'ol', 'li', 'blockquote', 'a', 'img',
                'table', 'thead', 'tbody', 'tr', 'th', 'td'],
  allowedAttributes: { a: ['href', 'title'], img: ['src', 'alt', 'width', 'height'] },
  allowedSchemes: ['http', 'https', 'mailto'],   // blocks javascript: and data:
};

app.use('/collab/:docId', (req, res, next) => {
  // AsyncSave posts the document as "body", VersionControl as "snapshot".
  if (typeof req.body?.body === 'string') {
    req.body.body = sanitizeHtml(req.body.body, POLICY);
  }
  if (typeof req.body?.snapshot === 'string') {
    req.body.snapshot = sanitizeHtml(req.body.snapshot, POLICY);
  }
  next();
});

That covers the write path. Apply the same policy again on the read path, wherever stored content gets rendered outside the editor, because rows written before this middleware existed have never been through it.

const { content } = await fetchDocument(docId);
res.render('document', { html: sanitizeHtml(content, POLICY) });

Encrypt everything in transit

Serve the page over HTTPS and the relay over wss://. If you terminate TLS at nginx or a cloud load balancer, confirm that the proxy forwards the Upgrade and Connection headers, and that its idle timeout is longer than the relay’s 30 second keepalive ping. A proxy that quietly drops upgrades produces a connection that reconnects forever and syncs nothing.

# nginx.conf

location / {
  proxy_pass http://127.0.0.1:3000;

  # Without these three lines the upgrade never reaches Node
  # and the browser reconnects forever.
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";

  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-Proto $scheme;

  # Comfortably longer than the relay's 30 second keepalive ping.
  proxy_read_timeout 120s;
}

Then move the client onto the secure scheme. An HTTPS page is not permitted to open a plain ws:// connection at all, so this is not optional once TLS is on.

collabConfig: {
  // wss, not ws, and the same host the page was served from.
  realTime: { syncUrl: 'wss://collab.example.com' },
  commentsUrl: 'https://collab.example.com/collab/my-doc/comments',
  // ...
}

Pick a database shape that matches your deployment

A single SQLite file is a perfectly respectable production choice for one server instance, and it is fast. Enable write-ahead logging once on the file so readers do not block writers, and back the file up on a schedule like any other database.

const Database = require('better-sqlite3');
// Run once before attachRoutes. journal_mode is stored in the file itself.
new Database(dbPath).pragma('journal_mode = WAL');

Scaling past one instance is where this needs thought. The relay keeps its room registry in process memory, so two Node processes behind a load balancer are two separate sets of rooms, and users in the same document land in different ones. Start with a single instance. When you outgrow it, add session affinity so every connection for a given docId reaches the same process, and move the REST stores onto a shared database that all instances can see.

#nginx.conf

upstream collab_backend {
  # The WebSocket path is exactly /{docId}, so hashing on the URI
  # lands every peer of a document on the same process.
  hash $request_uri consistent;

  server 10.0.0.11:3000;
  server 10.0.0.12:3000;
}

Decide your retention policy before launch rather than after. Versions are append-only and each snapshot holds a full copy of the document, so an autosave interval of one minute on a busy document produces a lot of rows. Choose how long snapshots live, what happens to comments and suggestions when a document is deleted, and who is allowed to delete them.

// cleanup.js · run nightly

const Database = require('better-sqlite3');
const db = new Database(dbPath);
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000;

// Keep every version a person deliberately named.
// Expire the automatic ones, which is where the volume comes from.
const removed = db.prepare(
  `DELETE FROM versions
   WHERE is_named = 0 AND source = 'auto' AND created_at < ?`
).run(Date.now() - THIRTY_DAYS);

console.log(`Pruned ${removed.changes} autosaved versions`);

Guard the edges

Rate-limit both WebSocket connections per user and REST writes per document, since a client bug can otherwise turn into a write loop against your database. Validate docId against a strict character allow-list before it reaches a route, because it flows into URL paths and database keys. Keep the body limit generous enough for your largest realistic document but not unlimited. And know that the relay silently drops any single message larger than 1 MB, which is worth logging on the client if you expect users to paste very large content.

// server.js · register before attachRoutes

const rateLimit = require('express-rate-limit');

// docId flows into URL paths and database keys. Validate it early
// and reject anything that is not plainly a document name.
const DOC_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;

app.use('/collab/:docId', (req, res, next) => {
  if (!DOC_ID.test(req.params.docId)) {
    return res.status(400).json({ error: 'invalid docId' });
  }
  next();
});

// Reads are cheap. Writes are what a client bug turns into a loop.
const writes = rateLimit({
  windowMs: 60_000,
  limit: 120,
  // Budget per person per document, not per IP, so one busy office
  // behind one address does not throttle itself.
  keyGenerator: (req) => `${req.user.id}:${req.params.docId}`,
});

app.use('/collab/:docId', (req, res, next) =>
  req.method === 'GET' ? next() : writes(req, res, next)
);

 Do not hand out database errors

When something goes wrong at the storage layer, the endpoints answer with a 500 whose error field carries the underlying SQLite message. That is helpful in development and a small gift to anyone probing your application in production, because it names tables and columns. Mask it at the edge and keep the detail in your logs.

// Register before attachRoutes so it wraps the SDK's own responses.
app.use('/collab/:docId', (req, res, next) => {
  const send = res.json.bind(res);

  res.json = (body) => {
    if (res.statusCode >= 500) {
      console.error('collab %s %s', req.method, req.originalUrl, body);
      return send({ error: 'server error' });
    }
    return send(body);
  };

  next();
});

Send images to storage, not into the document

This one connects two limits that look unrelated. An image pasted or dropped into the editor without an upload target is embedded in the HTML as a data URI. That inflates every version snapshot, pushes requests against your body limit, and can produce a single sync message above the relay’s 1 MB ceiling, at which point the message is silently dropped and peers simply never see the paragraph. Configuring uploads turns the image into a short link and the problem disappears.

new FroalaEditor('#editor', {
  // Your endpoint stores the file and returns { "link": "https://..." }
  imageUploadURL: 'https://collab.example.com/upload/image',

  // Client-side hints that improve the error message. The server
  // still has to validate size and type for itself.
  imageMaxSize: 5 * 1024 * 1024,
  imageAllowedTypes: ['jpeg', 'png', 'gif', 'webp'],

  events: {
    'image.error': function (error) {
      console.warn('Image upload failed', error);
    },
  },
});

Expect restarts to disconnect everyone

Room state lives in process memory, so a deploy ends every session on that instance at once. Nothing is lost if your persistence URLs are configured and clients reconnect, but an abrupt close is a poor experience. Tell peers first, then stop accepting connections.

process.on('SIGTERM', () => {
  // 1001 is "going away". Clients reconnect after reconnectDelay.
  for (const client of wss.clients) client.close(1001, 'server restarting');

  // Stop taking new work and exit once open requests finish.
  server.close(() => process.exit(0));

  // Do not hang forever on a stuck connection.
  setTimeout(() => process.exit(1), 10000).unref();
});

Roll one instance at a time rather than all at once, and if you are running with session affinity, remember that draining an instance moves its documents to a different process. Peers reconnect into a fresh empty room, which is exactly the situation step 7 covers, so the starting content path has to be solid before you rely on rolling deploys.

Running it in a container

Two details bite here. better-sqlite3 compiles against the Node version in the image, so a mismatch between build and runtime produces a module that will not load. And a SQLite file written to the container filesystem is deleted with the container, which is a very quiet way to lose every comment on your first redeploy.

#Dockerfile
FROM node:22-bookworm-slim

# Needed only if no prebuilt binary matches this platform.
RUN apt-get update \
 && apt-get install -y --no-install-recommends python3 make g++ \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

# The database must outlive the container. Mount a real volume here.
VOLUME ["/app/data"]

EXPOSE 3000
CMD ["node", "server.js"]

On a platform with an ephemeral filesystem and no volume support, that is your signal to move the REST stores to a managed database rather than fighting the platform.

See it working before you build it

Froala’s collaborative editing demo puts two editors side by side on one document, or gives you an invite link to open in a second window. Type on one side and watch cursors, comments, suggestions, and version history appear on the other.

It is also a live illustration of the central lesson here. The demo runs on an ephemeral relay with no persistence behind it, so everything is discarded the moment the last participant closes their tab. That is the relay alone, doing precisely what it is designed to do. Steps 3 and 7 of this guide are what you add to make it survive.

Posted on September 18, 2026

Mostafa Yousef

Senior web developer with a profound knowledge of the Javascript and PHP ecosystem. Familiar with several JS tools, frameworks, and libraries. Experienced in developing interactive websites and applications.

No comment yet, add your voice below!


Add a Comment

Your email address will not be published. Required fields are marked *