Skip to content

Self-hosting

The operator's guide. If you read one page before putting easydocs in front of colleagues, read this one — particularly TLS, forwarded headers, backups, and the logging warning.

The stack

cd easydocs/deploy/compose
cp .env.example .env      # then edit the secrets — see below
docker compose up --build

Three services:

Service Image What it is
easydocs built from Dockerfile The whole application: REST API, the React SPA served from wwwroot, the WOPI host, and in-process background workers. LibreOffice is bundled into the image for PDF rendering. Listens on 8080.
postgres postgres:16 Metadata, ACLs, audit trail. Not the document contents.
collabora collabora/code Collabora Online, for in-browser .docx editing over WOPI. Listens on 9980.

Two named volumes, both of which matter:

  • compose_pgdata/var/lib/postgresql/data. The database.
  • compose_blobs/data/blobs in the app container. The document contents, content-addressed by sha256. This is where every version of every .docx actually lives.

(Volume names are prefixed with the Compose project name, which defaults to the directory name — compose. docker volume ls will confirm yours.)

Migrations are automatic

EF Core migrations are applied on application startup, every start. There is no migrate step to run and no dotnet ef to install. A fresh database is created and migrated by the first boot.

The practical consequence: take a database backup before upgrading the image, because the new container will migrate your schema the moment it starts. See Backup and restore.

.env reference

Every variable in deploy/compose/.env.example.

Variable Secret? What it does
POSTGRES_USER no Postgres role the database is created for. Default easydocs.
POSTGRES_PASSWORD yes Password for that role. Ships as change-me. Change it.
POSTGRES_DB no Database name. Default easydocs.
ConnectionStrings__Postgres yes (contains the password) The app's connection string. Contains POSTGRES_PASSWORD a second time — change it in both places, to the same value, or the app cannot connect.
BLOB_ROOT no Where blobs are written inside the container. /data/blobs, matching the volume mount. The app refuses to start if this is unset.
Jwt__Secret yes HS256 signing key for session cookies and WOPI access tokens. Must be ≥ 32 bytes. See below.
PUBLIC_BASE_URL no The URL browsers use to reach easydocs, e.g. https://docs.example.com. Also allowlisted into Collabora's aliasgroup1. Get this wrong and the editor iframe will not load.
WOPI_HOST_URL no The URL Collabora uses to reach easydocs, from inside the Docker network — http://easydocs:8080. Not the public URL. This is why there are two.
COLLABORA_URL no Where the app reaches Collabora to fetch its discovery document — http://collabora:9980.
RateLimit__* no Rate-limit overrides, all commented out by default. See Rate limiting.

Everything uses ASP.NET's double-underscore convention: Jwt__Secret in the environment is the Jwt:Secret configuration key. Any configuration key can be set this way, whether or not it appears in .env.example.

Jwt__Secret must be at least 32 bytes

HS256 requires a 256-bit key. If Jwt:Secret is missing or shorter than 32 bytes, the application throws at boot:

Jwt:Secret must be configured and at least 32 bytes (HS256 requires a 256-bit key).

This is deliberate. The alternative — starting successfully and failing on the first login, or worse, signing tokens with a weak key — is strictly harder to diagnose and strictly less safe. Generate one:

python3 -c 'import secrets; print(secrets.token_urlsafe(48))'
# or: openssl rand -base64 48

Rotating Jwt__Secret invalidates every session and every open editor

It signs session cookies and WOPI tokens. Change it and everyone is logged out, and any Collabora editing session open at that moment fails. Do it during a quiet window. It does not affect stored passwords, ed_ API tokens, share links, or invitations — none of those are derived from it.

PUBLIC_BASE_URL vs WOPI_HOST_URL

The most common wiring mistake. The browser and the Collabora container see easydocs at different addresses, so both must be configured:

Browser ──── PUBLIC_BASE_URL (https://docs.example.com) ────► easydocs
   │
   └──────── PUBLIC_BASE_URL ────► Collabora editor iframe
                                       │
                                       └── WOPI_HOST_URL (http://easydocs:8080) ──► easydocs
                                           (CheckFileInfo / GetFile / PutFile, inside the network)

PUBLIC_BASE_URL is also passed to Collabora as aliasgroup1, which is Collabora's host allowlist. If it does not match the host the browser actually used, Collabora refuses the request.

TLS is mandatory in practice

Not "recommended". The session cookie is Secure, which means a browser will only ever send it back over HTTPS — or to localhost.

http://localhost:8080 works because browsers treat localhost as a secure context. That is the only plain-HTTP case that works.

The failure mode is silent and looks like a bug in easydocs

Serve easydocs over plain HTTP on a LAN IP or an internal hostname — http://192.168.1.50:8080, http://easydocs.internal — and login will appear to succeed and then silently fail. The API authenticates the user and sets the cookie; the browser discards it because the connection is not secure; the next request is unauthenticated and the user lands back on the sign-in screen. There is no error message, because from the server's point of view nothing went wrong.

Every "I can log in but it just bounces me back" report is this.

Terminate TLS at a reverse proxy in front of the app. Dropping Secure from the cookie is not a supported workaround — it would hand every session cookie to anyone who can watch the network.

Caddy

Two lines, and it obtains and renews a certificate itself. Start here.

# /etc/caddy/Caddyfile
docs.example.com {
    reverse_proxy localhost:8080
}

Caddy sets X-Forwarded-For and X-Forwarded-Proto correctly by default, so you only need to tell easydocs to trust them — see forwarded headers.

Server-sent events (the live-updating document console) need response buffering off. Caddy does not buffer by default, so there is nothing to do. If you have added flush_interval, set it to -1.

nginx

server {
    listen 443 ssl http2;
    server_name docs.example.com;

    ssl_certificate     /etc/letsencrypt/live/docs.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/docs.example.com/privkey.pem;

    # .docx uploads. Raise to suit your largest document; the default 1m is too small.
    client_max_body_size 200m;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;

        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;

        # Server-sent events: the document console streams from
        # /api/v1/documents/{id}/events and must not be buffered.
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 3600s;
    }
}

server {
    listen 80;
    server_name docs.example.com;
    return 301 https://$host$request_uri;
}

Then set PUBLIC_BASE_URL=https://docs.example.com in .env and restart the stack.

Collabora behind the same proxy

The compose file runs Collabora with --o:ssl.enable=false --o:ssl.termination=true: it speaks plain HTTP inside the Docker network and expects TLS to be terminated at the edge. If you expose Collabora publicly (port 9980), proxy it with TLS as well, using a WebSocket-capable configuration. The default compose setup keeps app↔Collabora traffic on the internal network, which is the simpler and safer arrangement.

Forwarded headers behind a proxy

Behind a reverse proxy, set this:

ASPNETCORE_FORWARDEDHEADERS_ENABLED=true

Without it, RemoteIpAddress is the proxy's address on every request. Two things break:

  1. Per-IP rate limits become install-wide. Every caller collapses onto one partition key, so the whole installation shares a single budget. Note that this fails closed, not open: the limits become stricter than intended, not looser. Nobody gets extra allowance; everybody shares one.
  2. The ip field in the share-view audit row is wrong on every public view. It records the proxy's address rather than the recipient's, which quietly makes that part of the audit trail useless.

Why it is not on by default

Because trusting X-Forwarded-For unconditionally would be a security hole. The header is client- supplied. If the app believed it without knowing which hop is trustworthy, anyone could bypass the per-IP rate limiter by sending a different X-Forwarded-For on each request — turning a protection into an open door. Enabling it is a statement that a proxy you control is in front of the app and is overwriting that header.

What enabling it actually does — read this before you enable it

ASPNETCORE_FORWARDEDHEADERS_ENABLED=true is a framework switch, not an easydocs one, and it does two things. ASP.NET Core's ForwardedHeadersOptionsSetup is the whole implementation:

options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// Only loopback proxies are allowed by default. Clear that restriction because forwarders are
// being enabled by explicit configuration.
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();

So it processes X-Forwarded-For and X-Forwarded-Proto — which is what fixes RemoteIpAddress — and it clears the loopback-only restriction, trusting the forwarded headers from any peer.

Enabling this means the app must not be directly reachable

Because the trusted-proxy restriction is cleared, anything that can open a TCP connection to easydocs can now dictate its own client IP and get a fresh rate-limit budget per request. Behind a proxy that overwrites the header, that is fine. Directly reachable, it is a rate-limiter bypass.

Bind the app's port to the proxy only. In deploy/compose/docker-compose.yml, change:

ports:
  - "8080:8080"          # listens on every interface — do not combine with forwarded headers

to:

ports:
  - "127.0.0.1:8080:8080"   # reachable only by a proxy on this host

If the proxy runs in another container, drop the ports: mapping entirely and put both on a shared Docker network, so the app is reachable only over that network.

Narrowing trust to named proxies (v1.1+)

Since v1.1 the trusted-proxy lists bind from configuration, so you can name the proxy instead of relying on port binding alone:

ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
ForwardedHeaders__KnownProxies__0=10.0.0.5        # exact proxy IPs…
ForwardedHeaders__KnownNetworks__0=172.16.0.0/12  # …or CIDR ranges (Docker networks live here)

With either list set, X-Forwarded-* headers are honored only from those addresses; a connection from anywhere else is treated as the client itself. Both lists are additive — set as many indexed entries as you have proxies.

Two properties worth knowing, both deliberate:

  • Misconfiguration aborts boot. An entry that isn't an IP / CIDR, or lists set while ASPNETCORE_FORWARDEDHEADERS_ENABLED is off (which would make them dead config), stops the app at startup with a message naming the problem. On easydocs v1.0 these keys were silently ignored — never again.
  • Network isolation is still worth keeping. The port-binding advice above remains good defense-in-depth even with named proxies; the lists narrow header trust, not reachability.

Verify it worked

Create a share link, open it from another machine, and check the document's audit trail. The ip in the share_link.viewed row should be the visitor's address, not your proxy's.

Rate limiting

Four named policies, applied per endpoint rather than globally. Static assets and the SPA shell are deliberately not metered: serving index.html is not abuse, and a throttled asset load reads as an outage rather than as a limit.

Policy Applies to Default Partitioned by
anon-share GET /s/{token} 120 per 60s, fixed window client IP
anon-download GET /s/{token}/download 30 per 60s, fixed window client IP
auth POST /api/v1/auth/login, POST /api/v1/auth/register token bucket: 1000 burst, +50 every 10s ⇒ ~300/min sustained request path and client IP, so login and register meter independently
token-mint POST /api/v1/tokens 20 per 60s, fixed window authenticated user id

Rejections are 429 with problem+json and a Retry-After header.

Tuning

Every value is configurable under RateLimit:<Policy>:*. The exact keys, as read by the code in src/EasyDocs.Api/Common/RateLimits.cs:

RateLimit__AnonShare__PermitLimit=120
RateLimit__AnonShare__WindowSeconds=60

RateLimit__AnonDownload__PermitLimit=30
RateLimit__AnonDownload__WindowSeconds=60

RateLimit__TokenMint__PermitLimit=20
RateLimit__TokenMint__WindowSeconds=60

RateLimit__Auth__BurstLimit=1000
RateLimit__Auth__TokensPerPeriod=50
RateLimit__Auth__ReplenishmentSeconds=10

When to change them:

  • Raise AnonDownload__PermitLimit if a whole team opens one share link at once. 30/min of a multi-megabyte .docx is the egress cap, and it is the limit that will bite first in normal use.
  • Tighten Auth__* if the install is public. The shipped defaults are a flood brake, not an anti-stuffing control: they stop one client burning unbounded Argon2id CPU or creating unbounded organizations. They do not stop patient or distributed credential stuffing — that is a WAF or fail2ban job at the proxy. The defaults are loose partly because a whole office behind one NAT must keep working when forwarded headers are not configured.
  • Remember token-mint is per user, not per IP, so a shared proxy address does not collapse it.

Backup and restore

Two things must be backed up together

The Postgres database and the blob volume. The database holds every version row, ACL, and audit event; the blob volume holds the actual .docx bytes. A database without its blobs is useless — you get a complete, browsable history in which every download 500s. Blobs without a database are an undifferentiated pile of sha256-named files.

Back them up in the same run, and restore them as a pair.

Backup

cd easydocs/deploy/compose
set -a; . ./.env; set +a
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p ~/easydocs-backups

# 1. The database.
docker compose exec -T postgres \
  pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom \
  > ~/easydocs-backups/easydocs-db-$STAMP.dump

# 2. The blobs. Read the volume through a throwaway container.
docker run --rm \
  --volumes-from "$(docker compose ps -q easydocs)" \
  -v ~/easydocs-backups:/backup \
  alpine tar czf /backup/easydocs-blobs-$STAMP.tar.gz -C /data blobs

Both files together are a complete backup. Take them while the stack is running if you must — the pg_dump is consistent, and blobs are immutable and content-addressed, so the only race is a version written between the two commands, which restores as a database row whose blob is missing. If you need a strictly consistent pair, stop the easydocs service first (leaving postgres up):

docker compose stop easydocs
# ... run both commands above ...
docker compose start easydocs

Verify a backup is not empty — an untested backup is a guess:

ls -lh ~/easydocs-backups/easydocs-{db,blobs}-$STAMP.*
docker run --rm -v ~/easydocs-backups:/b alpine \
  tar tzf /b/easydocs-blobs-$STAMP.tar.gz | head

Restore

Into a fresh stack, with the app stopped so migrations do not race the restore:

cd easydocs/deploy/compose
set -a; . ./.env; set +a
STAMP=<the stamp you are restoring>

docker compose up -d postgres        # database only
docker compose stop easydocs 2>/dev/null || true

# 1. The database, into a clean one.
docker compose exec -T postgres \
  dropdb -U "$POSTGRES_USER" --if-exists "$POSTGRES_DB"
docker compose exec -T postgres \
  createdb -U "$POSTGRES_USER" "$POSTGRES_DB"
docker compose exec -T postgres \
  pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
  < ~/easydocs-backups/easydocs-db-$STAMP.dump

# 2. The blobs, back into the volume.
docker compose create easydocs       # create the container so its volume exists
docker run --rm \
  --volumes-from "$(docker compose ps -aq easydocs)" \
  -v ~/easydocs-backups:/backup \
  alpine sh -c 'rm -rf /data/blobs && tar xzf /backup/easydocs-blobs-'"$STAMP"'.tar.gz -C /data'

docker compose up -d

Restore the database first, then the blobs, then start the app — its startup migration will bring an older schema forward. Restoring a newer dump into an older image is not supported; migrations only run forward.

Then check that a download actually works, not just that the app boots. Downloading an old version is the only thing that proves both halves of the restore landed.

Never raise the ASP.NET Core log level in production

This one leaks live credentials to stdout

The WOPI access_token travels in the query string, because that is what the WOPI protocol specifies. ASP.NET Core's request logging prints full request URLs at Information level.

The only thing preventing live WOPI tokens from being written to your container logs is this, in appsettings.json:

"Logging": { "LogLevel": { "Microsoft.AspNetCore": "Warning" } }

Setting Logging__LogLevel__Microsoft.AspNetCore=Information — or Debug, or Traceprints live WOPI access tokens to stdout, where they land in your Docker logs, your log shipper, and anything downstream of it. Those tokens authorize reading and writing the document.

Compose passes environment variables straight through to the app, so this is one .env line away from happening by accident. Do not raise that log level in production.

If you must debug WOPI, do it on a throwaway instance with throwaway documents, and treat the resulting logs as secret material. The tokens expire after 30 minutes, which limits the window but does not close it.

Logging__LogLevel__Default=Debug is a different, safer knob — it raises easydocs' own application logging without turning on framework request logging. Prefer it.

Upgrading

cd easydocs/deploy/compose
git pull
# Take a backup FIRST — the new container migrates your schema on startup.
docker compose up -d --build

Watch the logs on first start. Migrations run before the app serves traffic; if one fails, the app does not start, and the log line tells you which.

Health and monitoring

curl -fsS http://localhost:8080/health
# {"status":"ok"}

/health is unauthenticated and unmetered, suitable for a load-balancer probe. It confirms the process is serving; it does not probe Postgres, Collabora, or the blob volume. For a fuller check, request a document listing with an ed_ token — that exercises the database — and download a version, which exercises the blob store.

Things worth alerting on that easydocs does not alert on for you:

  • Free space on the blob volume. Versions are retained forever by design; there is no garbage collection. Disk use only grows.
  • PDF rendering failures. LibreOffice runs as a child process with a timeout and retry; a publish whose PDF never appears shows up in the application log, not in the UI.
  • Collabora reachability. If Collabora is down, editing fails while the rest of the app is fine, so /health stays green.

Not in v1

  • ~~No S3 blob backend.~~ Since v1.1 set BlobStore=s3 plus the S3__* keys to store blobs in any S3-compatible bucket (AWS, MinIO, Cloudflare R2) instead of the filesystem volume:

    BlobStore=s3
    S3__Bucket=easydocs-blobs        # must already exist; easydocs never creates buckets
    S3__AccessKey=S3__SecretKey=S3__ServiceUrl=https://minio.internal:9000   # MinIO/R2/Ceph; omit for real AWS…
    S3__Region=eu-central-1                      # …and set the region instead
    # S3__ForcePathStyle=false                   # default true, which is what MinIO wants
    

    Objects are keyed by their sha256 exactly like files on the volume, so the layout stays content-addressed and write-once. An unknown BlobStore value, or BlobStore=s3 with a missing key, aborts boot rather than falling back to the filesystem. Migrating existing installs: copy every file under BLOB_ROOT (skip .tmp/) into the bucket with its filename as the object key, then switch the config. There is no automatic migration. - ~~No OIDC/SSO and no MFA.~~ Since v1.1 both exist:

    • OIDC/SSO: point easydocs at any OpenID Connect provider and a "Sign in with SSO" entry appears on the login screen. First-time SSO users are provisioned by their verified email (an IdP reporting email_verified=false is refused) and get their own organization, exactly like self-serve registration; joining an existing org remains invitation-based.

      Oidc__Authority=https://login.example.com/realms/acme
      Oidc__ClientId=easydocs
      Oidc__ClientSecret=# Redirect URI to register with the provider: {PUBLIC_BASE_URL}/api/v1/auth/oidc/callback
      
    • MFA: per-account opt-in TOTP with single-use recovery codes, under Settings. There is no org-wide enforcement yet — if MFA must be mandatory for everyone, an authenticating reverse proxy still does that. SSO sign-ins don't get a local TOTP prompt; the IdP owns MFA there.

    • No antivirus scanning on upload. v1 trusts .docx files from authenticated organization members. If your threat model includes malicious members, scan the blob store out of band.
    • ~~No blob garbage collection.~~ Since v1.1 a daily sweep deletes blobs referenced by no version, PDF, or diff cache — with a 24-hour grace window so in-flight commits are never eaten. Versions are immutable, so anything your history points at is permanent by construction; what the sweep reclaims is the residue of failed commits. Tune with BlobGc__IntervalSeconds / BlobGc__GraceSeconds, or set BlobGc__Enabled=false to keep the old never-delete behaviour.
    • ~~No durable job queue.~~ Since v1.1 diff and PDF jobs are rows in the BackgroundJobs table, enqueued in the same transaction as the work that needs them: a restart picks queued jobs back up, a failing job retries with a two-minute backoff, and a job that fails five times is dropped with its payload in the application log.

See SECURITY.md in the repository for the full list of known v1 limitations and how to report a vulnerability.