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.
| Component | Where it lives here | What to plan for |
|---|---|---|
| HedgeDoc 1.12.0 | Container app, port 3000 on 127.0.0.1 | Pin the tag; read release notes first |
| Postgres 18 | Container database, named volume | Nightly pg_dump |
| Uploads | Named volume at /hedgedoc/public/uploads | Grows with every pasted image |
| nginx | Host package, ports 80 and 443 | Websocket 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 anyufw 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:3000with 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
psnor shell history ever shows them. Hex keeps the password valid inside theCMD_DB_URLURL; 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/postgresqlinstead.
The settings that matter before it goes public
Environment variables override config.json. Every variable used here is on the configuration page:
| Variable | Default | Set here | Effect |
|---|---|---|---|
CMD_DOMAIN | unset | your hostname | Base of every link and asset URL |
CMD_PROTOCOL_USESSL | false | true | Links use https:// because nginx terminates TLS |
CMD_URL_ADDPORT | false | false | No :3000 in links; visitors arrive on 443 |
CMD_SESSION_SECRET | random per start | 64 hex characters | Sessions survive restarts |
CMD_ALLOW_ANONYMOUS | true | false | Guests cannot create notes |
CMD_ALLOW_ANONYMOUS_EDITS | false | left at false | Guests cannot edit notes either |
CMD_ALLOW_EMAIL_REGISTER | true | false | No self sign-up; email login still works |
CMD_DEFAULT_PERMISSION | editable | limited | New 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:
docker compose exec app node /hedgedoc/bin/manage_users --add you@example.com, then enter the password at the prompt.- Repeat per colleague;
--resetchanges a password and--delremoves an account. - Avoid
--pass: a password passed as an argument is visible inpsand 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.