Use case · Collaborative editor

HedgeDoc self hosted on a dedicated server

Self-hosting HedgeDoc means running the official 1.12.0 container next to a Postgres container, publishing it on 127.0.0.1 only, and putting nginx in front with the socket.io websocket upgrade. Four settings decide whether it is safe to expose: CMD_DOMAIN with CMD_PROTOCOL_USESSL, a fixed CMD_SESSION_SECRET, and guests and open sign-up switched off. This page sets up each one on a machine you hold root on.

See the machines

What HedgeDoc is and what it runs on

HedgeDoc is a real-time collaborative markdown editor: several people type into one note and see each other's changes as they happen. The code started with the team behind HackMD and was later forked into its own organisation. The stable line is 1.x; the newest release at the time of writing is 1.12.0 (August 2026), and the Docker guide pins that tag.

It is a Node.js application. Release 1.12.0 needs Node 20.17 or later for a manual install; the container image carries its own runtime. Notes, users and revisions live in a database, and the docs strongly recommend something other than SQLite in production. Uploaded images go to the local filesystem by default, with S3, MinIO, Azure, Imgur and Lutim as alternatives.

ComponentWhere it lives hereWhat to plan for
HedgeDoc 1.12.0Container app, port 3000 on 127.0.0.1Pin the tag; read release notes first
Postgres 18Container database, named volumeNightly pg_dump
UploadsNamed volume at /hedgedoc/public/uploadsGrows with every pasted image
nginxHost package, ports 80 and 443Websocket upgrade on /socket.io/

HedgeDoc's docs list no hardware minimum, so there is no official CPU or memory figure to quote. Size by what the team will store: text revisions are small, uploads are not.

Picking the machine

One team's editor is a single service plus its database. On the dedicated server range, the x86 Moonshot cartridges are sold as one service per machine (not the X-Gene ARM cartridge: it boots only Ubuntu 16.04 and 18.04), and a single-socket rack server is described as a web or application server with the database on the same box. Both give you every core, root, and a port with no transfer allowance, so image-heavy weeks cost nothing extra.

  • Console. Every racked single-, dual- and quad-socket server has a full out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC); among cartridges only the m510 and m710x carry one, and the others offer power control alone. A console is how you recover from a bad ufw rule.
  • Delivery. A racked machine is handed over in about 30 minutes once payment is approved; a built-to-order line takes 4 to 24 hours.
  • Disk before cores. If people paste screenshots all day, buy storage rather than processors.

Choose the city nearest your writers; the data centre page lists all five with a test IP for each.

Firewall, env files and the compose file

This assumes Ubuntu, Docker Engine with the Compose plugin from Docker's repository, and nginx from the distribution. Use Docker Engine 28 or later: from that release a port published on 127.0.0.1 answers on the server itself only (moby#45610). Run as root and substitute your hostname for notes.example.com:

sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw allow 80,443/tcp
ufw --force enable
mkdir -m 700 /var/backups/hedgedoc
install -d -m 700 /opt/hedgedoc && cd /opt/hedgedoc
umask 077
PW=$(openssl rand -hex 24)
cat > db.env <<EOF
POSTGRES_USER='hedgedoc'
POSTGRES_PASSWORD='$PW'
POSTGRES_DB='hedgedoc'
EOF
cat > app.env <<EOF
NODE_ENV='production'
CMD_DB_URL='postgres://hedgedoc:$PW@database:5432/hedgedoc'
CMD_DOMAIN='notes.example.com'
CMD_PROTOCOL_USESSL='true'
CMD_URL_ADDPORT='false'
CMD_SESSION_SECRET='$(openssl rand -hex 32)'
CMD_ALLOW_ANONYMOUS='false'
CMD_ALLOW_EMAIL_REGISTER='false'
CMD_DEFAULT_PERMISSION='limited'
CMD_ENABLE_STATS_API='false'
CMD_IMAGE_UPLOAD_TYPE='filesystem'
EOF
unset PW
chmod 600 db.env app.env
cat > compose.yaml <<'EOF'
services:
  database:
    image: postgres:18.6-alpine
    env_file: db.env
    volumes:
      - database:/var/lib/postgresql
    restart: always
  app:
    image: quay.io/hedgedoc/hedgedoc:1.12.0
    env_file: app.env
    volumes:
      - uploads:/hedgedoc/public/uploads
    ports:
      - "127.0.0.1:3000:3000"
    restart: always
    depends_on:
      - database
volumes:
  database:
  uploads:
EOF
docker compose up -d
  • IPv6 first. ufw only writes IPv6 copies of rules added while IPV6=yes, so the sed runs before any ufw allow. nginx is an ordinary host process, so ufw has to let 80 and 443 through for it over both protocols.
  • Why 127.0.0.1. The docs' sample maps 3000:3000 with no address, which publishes on every host address (0.0.0.0 and [::]). Published ports skip ufw's rules on IPv4, and on IPv6 too when Docker's own IPv6 networking is on. Loopback leaves nginx as the only way in.
  • Secrets. Both values are generated inside the shell, never typed, so neither ps nor shell history ever shows them. Hex keeps the password valid inside the CMD_DB_URL URL; single quotes keep each value literal.
  • The Postgres volume. The docs' sample mounts /var/lib/postgresql/data. The Postgres 18 image, though, sets PGDATA to /var/lib/postgresql/18/docker. Given a mount at the old path, it refuses to initialise and exits with an error naming the unused mount. Mount /var/lib/postgresql instead.

The settings that matter before it goes public

Environment variables override config.json. Every variable used here is on the configuration page:

VariableDefaultSet hereEffect
CMD_DOMAINunsetyour hostnameBase of every link and asset URL
CMD_PROTOCOL_USESSLfalsetrueLinks use https:// because nginx terminates TLS
CMD_URL_ADDPORTfalsefalseNo :3000 in links; visitors arrive on 443
CMD_SESSION_SECRETrandom per start64 hex charactersSessions survive restarts
CMD_ALLOW_ANONYMOUStruefalseGuests cannot create notes
CMD_ALLOW_ANONYMOUS_EDITSfalseleft at falseGuests cannot edit notes either
CMD_ALLOW_EMAIL_REGISTERtruefalseNo self sign-up; email login still works
CMD_DEFAULT_PERMISSIONeditablelimitedNew notes hidden from signed-out visitors

The default permission matters even with guests off: editable lets signed-out visitors read a new note, while limited keeps it to signed-in users (protected and private are stricter still). Without a fixed secret, HedgeDoc generates a random one at every start and logs everybody out. With both anonymous options false and CMD_ENABLE_UPLOADS unset, uploads are limited to registered users. Setting CMD_ENABLE_STATS_API to false removes /status and /metrics, which the production guide advises against exposing.

Creating accounts

With registration closed, accounts come from the bundled command-line tool. The docs ask for NODE_ENV=production when it runs, which app.env already sets. From /opt/hedgedoc:

  1. docker compose exec app node /hedgedoc/bin/manage_users --add you@example.com, then enter the password at the prompt.
  2. Repeat per colleague; --reset changes a password and --del removes an account.
  3. Avoid --pass: a password passed as an argument is visible in ps and stays in shell history.

The production guide says the built-in username and password system works but lacks two-factor sign-in and self-service resets. For a larger team, LDAP, SAML and OAuth2 sign-in are documented alternatives.

nginx and the websocket path

The editor syncs over socket.io, so the proxy must pass websocket Upgrade requests on /socket.io/ and forward the client's scheme in X-Forwarded-Proto. Obtain a certificate for the hostname first (certbot's nginx plugin works over port 80), then add this site, adapted from the official reverse-proxy guide. One line is our addition to the guide's sample: proxy_http_version 1.1; in the socket.io block, because nginx proxies with HTTP/1.0 by default and a websocket upgrade needs 1.1.

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name notes.example.com;
    ssl_certificate /etc/letsencrypt/live/notes.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/notes.example.com/privkey.pem;
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    location /socket.io/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}

No trailing slash after a proxy_pass address: the guide warns that one breaks the websocket and leaves the editor spinning. On nginx 1.25.1 or later you may add http2 on;; older releases take http2 on the listen lines. Run nginx -t, reload, and sign in at your hostname.

Backups, restores and upgrades

The production guide names three things to keep: the database, the uploads and the configuration. The setup block already made /var/backups/hedgedoc at mode 700. Save this as a file:

# /etc/cron.d/hedgedoc-backup
20 3 * * * root umask 077; cd /opt/hedgedoc && docker compose exec -T database pg_dump -U hedgedoc hedgedoc > /var/backups/hedgedoc/db-$(date +\%F).sql
25 3 * * * root umask 077; cd /opt/hedgedoc && docker cp "$(docker compose ps -q app)":/hedgedoc/public/uploads - > /var/backups/hedgedoc/uploads-$(date +\%F).tar
30 3 * * * root umask 077; tar -C /opt/hedgedoc -cf /var/backups/hedgedoc/config-$(date +\%F).tar compose.yaml db.env app.env
50 3 * * * root find /var/backups/hedgedoc -type f -mtime +14 -delete

Those copies share disks with the live data, so pull them to another machine daily, and guard the config tar there too: it holds the secrets. For a restore, the Docker guide brings up only the database container and pipes the dump to psql as user hedgedoc via docker exec -i, before HedgeDoc's first start. The uploads tar goes back with docker cp -a - into /hedgedoc/public of the app container; -a keeps the original file owners.

HedgeDoc follows semantic versioning, so minor and patch releases should not break anything user-facing; the docs still want the release notes read before each upgrade. Routine: fresh dump, new image tag in compose.yaml, docker compose pull, docker compose up -d. Keep the Postgres major version as it is; a newer major cannot open the old data folder, so moving needs a dump and restore.

Licence and limits

HedgeDoc is licensed under the GNU Affero General Public License v3 (AGPL-3.0); the logo is excluded and has its own terms. Our general reading, not legal advice: running it unmodified for your own team triggers no extra duty. Distributing copies, changed or not, means passing on the licence and the corresponding source. Changing the code and letting users reach it over a network obliges you to offer those users your modified source; the configuration docs ask you to point CMD_SOURCE_URL at it. Ask a lawyer for anything beyond that.

It is the wrong tool for a structured, long-lived knowledge base with chapters and shelf-level permissions; a wiki such as BookStack or Outline fits that better. For personal notes synced to desktop and phone apps, Joplin Server is the closer match.

Questions

Does HedgeDoc need Postgres, or is SQLite enough?

SQLite works, and the docs accept it for a single-user instance. For anything shared they strongly recommend a separate database, which is sturdier than a single sqlite.db file. A second container costs little on a dedicated machine.

Why do links point to localhost or to port 3000?

HedgeDoc builds links from CMD_DOMAIN, CMD_PROTOCOL_USESSL and CMD_URL_ADDPORT. Behind an HTTPS proxy on the default port, set the domain to your hostname, the SSL flag to true and the port flag to false. The quick-start sample uses localhost with the port flag on, which suits local testing only.

The editor loads but never connects. What is wrong?

Almost always the websocket path. The proxy must pass Upgrade and Connection headers on /socket.io/, and nginx's proxy_pass must not end in a slash. If Cloudflare sits in front, the docs say Rocket Loader must be off.

Can guests still read notes once anonymous use is off?

Guests can no longer create notes, but under the default editable permission they could still read new ones. CMD_DEFAULT_PERMISSION='limited' in app.env closes that. Owners can still open a single note to guests from its permission menu.

Which server line should I start on?

An editor plus its database matches the x86 cartridges and the single-socket class on the dedicated server page. Choose by disk if uploads will be heavy, and prefer a line with an out-of-band console if you want a way back in after a firewall mistake.

Run your own HedgeDoc

Pick a dedicated server line, take root, and follow the steps above.

See the machines

Related on Server Room

Other use cases