Collaborative notes become critical infrastructure faster than most teams expect. A runbook starts as a scratchpad, then incident response notes, onboarding documents, architecture decisions, and customer handoff material begin living in the same place. HedgeDoc is a strong fit for this workflow because it gives engineers a fast Markdown editor, real-time collaboration, revision history, and a simple web interface without forcing a heavy knowledge-base rollout.
This guide shows how to deploy HedgeDoc on Ubuntu with Docker Compose, Caddy, and PostgreSQL. The goal is not only to get a login screen working; it is to build a small production service that is easy to patch, back up, observe, and recover. We will use Caddy for automatic HTTPS, PostgreSQL for durable storage, a locked-down environment file for secrets, and operational checks that prove the stack is healthy before people depend on it.
The pattern mirrors the house style used across our Guides: a clear architecture overview, copy-friendly command blocks, practical security defaults, explicit verification, common failure modes, a FAQ, and a short operations-focused call to action.
Architecture and flow overview
The deployment has three moving parts. Caddy listens on ports 80 and 443, obtains and renews TLS certificates, and proxies requests to HedgeDoc on the private Docker network. HedgeDoc serves the web application, collaborative editing sessions, and API endpoints. PostgreSQL stores users, notes, permissions, and application metadata on a persistent host-mounted volume.
Traffic flow is intentionally boring: browser to Caddy over HTTPS, Caddy to HedgeDoc over the internal container network, and HedgeDoc to PostgreSQL using credentials from an environment file. Operational flow is equally important. Configuration lives under /opt/hedgedoc, secrets are readable only by the deployment user, backups are created with pg_dump, and health checks are run after every change.
Prerequisites
- Ubuntu 22.04 or 24.04 server with 2 vCPU, 2-4 GB RAM, and 20+ GB free disk.
- A DNS A or AAAA record pointing
docs.example.comto the server. - Ports 80 and 443 open from the internet, plus SSH restricted to trusted admins.
- A sudo-capable Linux user and a plan for off-host backups.
Step-by-step deployment
1) Install Docker, Compose, and baseline tools
sudo apt update
sudo apt -y upgrade
sudo apt -y install ca-certificates curl gnupg lsb-release ufw jq openssl
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo usermod -aG docker "$USER"If the copy button does not work in your browser/editor, manually select the code block and copy.
Log out and back in after adding your user to the Docker group. Avoid running the whole stack as root during normal operations; reserve sudo for package and firewall changes.
2) Create the application directory
sudo mkdir -p /opt/hedgedoc/{postgres,uploads,backups}
sudo chown -R "$USER:$USER" /opt/hedgedoc
chmod 750 /opt/hedgedoc
cd /opt/hedgedocIf the copy button does not work in your browser/editor, manually select the code block and copy.
Keeping application data under one directory makes backups, audits, and migrations much easier. The uploads directory stores user-uploaded assets; the postgres directory stores the database volume.
3) Write secrets and application settings
cd /opt/hedgedoc
SESSION_SECRET=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -base64 36 | tr -d '\n')
cat > .env <<EOF
POSTGRES_DB=hedgedoc
POSTGRES_USER=hedgedoc
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
CMD_DOMAIN=docs.example.com
CMD_PROTOCOL_USESSL=true
CMD_URL_ADDPORT=false
CMD_ALLOW_ANONYMOUS=false
CMD_ALLOW_ANONYMOUS_EDITS=false
CMD_ALLOW_EMAIL_REGISTER=true
CMD_SESSION_SECRET=${SESSION_SECRET}
EOF
chmod 600 .envIf the copy button does not work in your browser/editor, manually select the code block and copy.
For a private company instance, disable anonymous access unless you intentionally want public note creation. If you use SSO later, keep email registration closed and map identity through your provider.
4) Create Docker Compose services
cat > /opt/hedgedoc/docker-compose.yml <<'YAML'
services:
postgres:
image: postgres:16-alpine
container_name: hedgedoc-postgres
restart: unless-stopped
env_file: .env
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
networks:
- hedgedoc_net
hedgedoc:
image: quay.io/hedgedoc/hedgedoc:1.10.2
container_name: hedgedoc-app
restart: unless-stopped
env_file: .env
environment:
CMD_DB_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
CMD_IMAGE_UPLOAD_TYPE: filesystem
CMD_CSP_ENABLE: true
volumes:
- ./uploads:/hedgedoc/public/uploads
depends_on:
postgres:
condition: service_healthy
networks:
- hedgedoc_net
caddy:
image: caddy:2-alpine
container_name: hedgedoc-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- hedgedoc
networks:
- hedgedoc_net
networks:
hedgedoc_net:
driver: bridge
volumes:
caddy_data:
caddy_config:
YAMLIf the copy button does not work in your browser/editor, manually select the code block and copy.
Pin the HedgeDoc image for predictable upgrades. Before moving to a new version, read release notes and test a database restore in staging or on a disposable clone.
5) Configure Caddy for HTTPS
cat > /opt/hedgedoc/Caddyfile <<'CADDY'
docs.example.com {
encode gzip zstd
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
reverse_proxy hedgedoc:3000
}
CADDYIf the copy button does not work in your browser/editor, manually select the code block and copy.
Caddy will request and renew certificates automatically when DNS is correct and ports 80/443 are reachable. Keep the domain in .env aligned with the Caddyfile so generated links are correct.
6) Start the stack and verify first boot
cd /opt/hedgedoc
docker compose pull
docker compose up -d
docker compose ps
docker logs --tail=80 hedgedoc-appIf the copy button does not work in your browser/editor, manually select the code block and copy.
The first start may take a moment while PostgreSQL initializes and HedgeDoc creates tables. Repeated restarts or database connection errors should be treated as deployment blockers, not harmless noise.
7) Add a database backup job
cat > /opt/hedgedoc/backup.sh <<'BASH'
#!/usr/bin/env bash
set -euo pipefail
cd /opt/hedgedoc
source .env
stamp=$(date +%F-%H%M%S)
out="/opt/hedgedoc/backups/hedgedoc-${stamp}.sql.gz"
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" hedgedoc-postgres \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" | gzip > "$out"
find /opt/hedgedoc/backups -type f -name 'hedgedoc-*.sql.gz' -mtime +14 -delete
BASH
chmod +x /opt/hedgedoc/backup.sh
( crontab -l 2>/dev/null; echo "23 2 * * * /opt/hedgedoc/backup.sh" ) | crontab -If the copy button does not work in your browser/editor, manually select the code block and copy.
Local backups are only the first layer. Sync backup files to object storage or another server, then run a restore drill at least quarterly.
Configuration and secrets handling best practices
Keep .env outside Git and restrict it to the deployment user. If multiple administrators need access, store generated secrets in a password manager or secret manager and document who can rotate them. Rotate CMD_SESSION_SECRET only during a planned session invalidation window, because active users may be logged out.
Prefer least privilege around SSH and Docker access. Membership in the Docker group effectively grants root-equivalent host control, so do not add casual users. For more formal environments, place deployment actions behind CI/CD or a restricted automation account and require review for Compose changes.
Verification checklist
Run these checks after first deployment and after every meaningful change:
cd /opt/hedgedoc
docker compose ps
curl -I https://docs.example.com
docker exec hedgedoc-postgres pg_isready -U hedgedoc -d hedgedoc
docker logs --tail=100 hedgedoc-app
docker logs --tail=50 hedgedoc-caddy
/opt/hedgedoc/backup.sh
ls -lh /opt/hedgedoc/backups | tailIf the copy button does not work in your browser/editor, manually select the code block and copy.
A healthy deployment returns an HTTPS response, shows stable containers, accepts PostgreSQL readiness checks, and produces a non-empty compressed backup. Create a test note, upload a small image if uploads are enabled, log out, and confirm authentication rules behave as expected.
Common issues and fixes
Caddy cannot issue certificates
Verify DNS points to this server and that ports 80 and 443 are not blocked by a cloud security group or UFW. Check docker logs hedgedoc-caddy for ACME challenge failures. Do not switch to a self-signed certificate to hide the issue; fix public reachability first.
HedgeDoc generates HTTP links behind HTTPS
Confirm CMD_PROTOCOL_USESSL=true, CMD_URL_ADDPORT=false, and CMD_DOMAIN exactly match the public hostname. Restart HedgeDoc after changing environment values.
Database password changes break startup
Changing POSTGRES_PASSWORD in .env does not automatically change the existing PostgreSQL user password inside the database. Rotate credentials intentionally with SQL, update the environment, and restart services in order.
Uploads disappear after redeploy
Make sure ./uploads:/hedgedoc/public/uploads remains mounted and included in backups. Database backups alone do not preserve uploaded files.
Users report editing conflicts
Check WebSocket proxy behavior, browser extensions, and any upstream load balancer timeouts. Caddy's basic reverse proxy configuration is usually sufficient, but external proxies in front of Caddy can introduce idle timeout problems.
FAQ
Can I use SQLite instead of PostgreSQL?
For production, use PostgreSQL. SQLite can be acceptable for experiments, but collaboration-heavy usage benefits from a real database, predictable backups, and operational visibility.
How do I disable open registration?
Set registration controls in the environment and restart the app. For private teams, combine closed registration with SSO or administrator-created accounts.
Should Caddy run on the host instead of Docker?
Either works. Containerized Caddy keeps the stack portable and is easier to version with Compose. Host Caddy can be better if one proxy serves many unrelated applications.
How often should I back up HedgeDoc?
Daily backups are a practical baseline, but high-change teams may want hourly database dumps plus continuous upload synchronization. Match retention to business impact.
What should I monitor first?
Monitor HTTPS availability, container restarts, disk usage, backup success, certificate expiry, and PostgreSQL readiness. Alerting on these basics catches most operational failures early.
How do I safely upgrade HedgeDoc?
Take a database and uploads backup, pin the current image tag, test the new tag in staging, then upgrade during a maintenance window. Keep rollback commands documented.
Can I put HedgeDoc behind a VPN only?
Yes. If notes contain sensitive operational data, private access through a VPN or zero-trust gateway can be a good default. Still keep TLS enabled and backups tested.
Related guides
- Production MinIO deployment guide
- Production Graylog deployment guide
- Production Meilisearch deployment guide
Talk to us
If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.