Skip to content
Oblish

Oblish install info

Updated 9 min read

Oblish is installed on traefik1.

Deploying Oblish

Oblish ships as one stateless container (Dockerfile) plus PostgreSQL 17. Everything that
persists lives in Postgres: pages, revisions, attachments, sessions, rate limits and jobs.

Quick start (Docker Compose)

cp .env.example .env
# edit .env:
#   ORIGIN=https://notes.example.com
#   OBLISH_ENCRYPTION_KEY=$(openssl rand -base64 48)
#   POSTGRES_PASSWORD=$(openssl rand -base64 24)
docker compose up -d --build

The app listens on 127.0.0.1:3000 (change it with OBLISH_PORT). Put a reverse proxy that
terminates TLS in front of it (see below), open the site and complete the setup wizard.

docker-compose.yml runs the app with a read-only root filesystem, a tmpfs on /tmp, all
capabilities dropped and no-new-privileges. The only volume is Postgres' data directory.

The image

  • Base image: node:24-alpine. The only native runtime module, @node-rs/argon2, has a
    prebuilt musl binary.
  • Runs as the unprivileged node user. /app is owned by root and can't be written to.
  • CMD ["node", "build"], port 3000, and a HEALTHCHECK on /healthz.
  • Database migrations in /app/drizzle run automatically at startup under a Postgres
    advisory lock, so any number of replicas can start at once.
  • Works with docker run --read-only --tmpfs /tmp. The app writes nothing outside /tmp, and
    in practice nothing there either.

Docker Swarm with an existing PostgreSQL

docker-stack.yml runs Oblish on Swarm against a Postgres server you already have (PostgreSQL 13+,
no extensions). The steps are in the file's header: create an empty database owned by an oblish
user, create two secrets (oblish_database_url, oblish_encryption_key), then
OBLISH_ORIGIN=https://… OBLISH_VERSION=<version> docker stack deploy -c docker-stack.yml oblish.

  • Secrets: the image accepts NAME_FILE for any variable (e.g. DATABASE_URL_FILE,
    OBLISH_ENCRYPTION_KEY_FILE, OIDC_CLIENT_SECRET_FILE) and reads the value from that file.
  • Replicas: two by default (OBLISH_REPLICAS). Rolling updates start the new task first and
    roll back automatically if it fails its health check. Migrations run at start under an advisory
    lock, so a new version migrates once.
  • Proxy: by default port 3000 is published through the routing mesh for an external proxy.
    With Traefik in the swarm, remove ports and use the commented labels. Either way the proxy
    must pass the original Host (needed for space domains) and set X-Forwarded-For /
    X-Forwarded-Proto.
  • Database connections: each replica opens a small pool. Make sure max_connections on the
    shared server leaves room for about 12 connections per replica (a pool of 10, plus listeners) plus your other apps.
  • Back up the database (pg_dump -Fc) before upgrading; it holds everything, including files.

Building and publishing images

Every commit has a version, <major>.<minor>.<commit count> (major and minor come from
package.json, e.g. 0.1.312). It is built into the app and shown at the bottom of the Settings
menu. Bump the minor in package.json for a notable release; the last number keeps counting.

Build on the server (recommended, costs nothing). Clone the repository on the server (a
read-only deploy key is enough), then for each update:

git pull
scripts/build-image.sh              # builds oblish:<version> and oblish:latest
docker compose up -d --no-build     # runs oblish:latest; set OBLISH_IMAGE=oblish:<version> to pin

Old builds stay available as oblish:<version> for a quick rollback; remove them with
docker image prune / docker rmi when you no longer need them.

Publish to GHCR (occasionally). The "Release image" workflow (Actions tab, run on a version
tag such as v0.1.312) smoke-tests that version and pushes ghcr.io/fullmetaled/oblish:0.1.312
for amd64 and arm64. The package is private like the repository, and private packages count
against the account's free storage (500 MB) and download (1 GB/month) allowances, so delete old
versions under the repository's Packages page. To pull on a server, create a GitHub personal
access token (classic) with only read:packages, then:

echo "$TOKEN" | docker login ghcr.io -u <github-user> --password-stdin
OBLISH_IMAGE=ghcr.io/fullmetaled/oblish:0.1.312 docker compose pull app
OBLISH_IMAGE=ghcr.io/fullmetaled/oblish:0.1.312 docker compose up -d --no-build

GitHub Actions minutes. CI runs on GitHub's hosted runners, which are free up to the private
repository quota (2,000 minutes a month on a free account). To stay inside it, CI skips docs-only
commits, cancels superseded runs, and the image smoke test runs only when the Dockerfile,
dependencies or migrations change. Set the account's Actions and Packages budgets to $0 with
"stop usage" so going over the quota pauses CI instead of billing.

Pin a version in production rather than latest, and back up the database before upgrading:
migrations run automatically on start.

docker build -t oblish .
docker run -d --name oblish --read-only --tmpfs /tmp -p 127.0.0.1:3000:3000 \
  -e DATABASE_URL=postgres://oblish:secret@db-host:5432/oblish \
  -e ORIGIN=https://notes.example.com \
  -e OBLISH_ENCRYPTION_KEY=... \
  oblish

Environment variables

Application

Variable Required Description
DATABASE_URL yes PostgreSQL connection string.
OBLISH_ENCRYPTION_KEY yes 32+ characters. Encrypts stored credentials. Must be the same on every replica. Back it up separately; see backup-restore.md.
ORIGIN yes in production Public URL, e.g. https://notes.example.com. The app uses it for absolute links, QR codes and OIDC callbacks.
OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_GROUPS_CLAIM, OIDC_ADMIN_GROUP no Single sign-on (e.g. Authentik).
MAX_ATTACHMENT_MB no Largest attachment in megabytes, default 1024. See Attachments.
OBLISH_EXTRA_HOSTS no Comma-separated host names also served as the main site (e.g. a LAN name). Any other Host that is not a space domain gets 404. See Spaces.

Server (adapter-node)

Variable Default in image Description
PORT / HOST 3000 / 0.0.0.0 Listen address.
BODY_SIZE_LIMIT 256M Largest request body allowed. Zip imports can reach about 200 MB and multipart uploads 64 MB; large attachments arrive in 4 MB chunks, so 8M is enough without big imports. The adapter's own default of 512K is far too small. Your proxy must allow the same size.
SHUTDOWN_TIMEOUT 20 How many seconds in-flight requests get to finish after SIGTERM. Keep it below your orchestrator's kill timeout. Docker's default is 10 s; raise it with docker stop -t 30 or stop_grace_period.
PROTOCOL_HEADER unset Header that carries the original scheme, e.g. X-Forwarded-Proto. If it is unset, the scheme is assumed to be https.
HOST_HEADER unset Header that carries the original host, e.g. X-Forwarded-Host. If it is unset, the Host header is used.
ADDRESS_HEADER unset Header that carries the client IP, normally X-Forwarded-For.
XFF_DEPTH 1 Number of trusted proxies in front of the app. The client IP is taken this many entries from the right of X-Forwarded-For.

ORIGIN does not set the request URL. In SvelteKit 3 / adapter-node 6, the request URL
comes from PROTOCOL_HEADER/HOST_HEADER, or https plus Host when they are unset. That URL
drives the CSRF origin check for forms, the Secure cookie flag and redirects. If logins fail
with "Cross-site POST form submissions are forbidden", the scheme or host the app sees doesn't
match what the browser sees. Set PROTOCOL_HEADER/HOST_HEADER to match your proxy. A
plain-HTTP deployment (no TLS) always needs PROTOCOL_HEADER, sent by a proxy.

Behind a reverse proxy

The client IP is used for login rate limiting and for share-link analytics, where only a
salted hash is stored. Without ADDRESS_HEADER, every request appears to come from the proxy and
all users share one rate-limit bucket. Set ADDRESS_HEADER/PROTOCOL_HEADER/HOST_HEADER only
when two things are true: the app can be reached only through the proxy, and the proxy
overwrites those headers instead of passing on values the client sent. Otherwise clients can
spoof them.

Typical settings:

ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1
PROTOCOL_HEADER=X-Forwarded-Proto
# HOST_HEADER=X-Forwarded-Host   # only if the proxy does not pass the original Host

Caddy

notes.example.com {
	request_body {
		max_size 256MB
	}
	reverse_proxy 127.0.0.1:3000
}

Caddy passes Host through and sets X-Forwarded-For/-Proto itself. It ignores values the
client sent unless you configure trusted_proxies. It flushes text/event-stream responses
(/api/v1/events, live updates) immediately. docker-compose.ha.yml also sets
flush_interval -1 explicitly.

nginx

server {
    listen 443 ssl;
    server_name notes.example.com;
    client_max_body_size 256m;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;   # overwrite, don't append
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;                             # /api/v1/events is server-sent events
        proxy_read_timeout 1h;                           # keep event streams open
    }
}

Spaces and custom domains

Spaces (spaces.md) can have their own domains, e.g. client1.example.com. Oblish
decides which site a request is for from its host name — the Host header, or HOST_HEADER if
set — so:

  • the proxy must pass the original Host for every domain (Caddy and the nginx example above
    do), or set a header named in HOST_HEADER;
  • ORIGIN stays the main address. A space domain's absolute URLs are built from the configured
    domain with ORIGIN's scheme and port, never from the request's Host;
  • requests for any other host name get a plain 404 (except /healthz, /readyz and
    /internal/tls-ask). If you reach Oblish under another name too (a LAN name), list it in
    OBLISH_EXTRA_HOSTS. Without ORIGIN (development) every non-space host is the main site;
  • each domain needs DNS (a CNAME to the main host name, or A/AAAA records) and a TLS
    certificate. With single sign-on, register https://<domain>/auth/oidc/callback as an extra
    redirect URI at the identity provider.

Caddy, on-demand TLS (certificates for domains as soon as an admin adds them). Caddy asks
Oblish before issuing; GET /internal/tls-ask?domain=<host> answers 200 only for the main host
and for domains of spaces whose site is enabled:

{
	on_demand_tls {
		ask http://127.0.0.1:3000/internal/tls-ask
	}
}

notes.example.com {
	reverse_proxy 127.0.0.1:3000
}

# every space domain
https:// {
	tls {
		on_demand
	}
	reverse_proxy 127.0.0.1:3000
}

Caddy, wildcard certificate (all spaces under one parent domain; needs a DNS provider module,
e.g. caddy-dns/cloudflare):

*.example.com, example.com {
	tls {
		dns cloudflare {env.CF_API_TOKEN}
	}
	reverse_proxy 127.0.0.1:3000
}

Traefik (labels on the Oblish container; one router for the main host, one for space
domains — Traefik needs each domain listed, or a wildcard certificate via a DNS challenge):

labels:
  - traefik.http.routers.oblish.rule=Host(`notes.example.com`) || Host(`client1.example.com`) || Host(`client2.example.com`)
  - traefik.http.routers.oblish.entrypoints=websecure
  - traefik.http.routers.oblish.tls.certresolver=le
  - traefik.http.services.oblish.loadbalancer.server.port=3000

Traefik passes Host through and sets X-Forwarded-Proto, so PROTOCOL_HEADER=X-Forwarded-Proto
is enough.

nginx: add the domains to server_name (or a server_name *.example.com; block with a
wildcard certificate) in the server block above; proxy_set_header Host $host; is required.

The host → space map is held in memory on every replica and refreshed via Postgres
NOTIFY oblish_spaces, so adding a domain takes effect everywhere within moments, without a
restart.

Attachments and storage

Attachments are stored in PostgreSQL (table blob_chunks, 4 MB chunks, uncompressed TOAST), so
the app container keeps no state and backups stay a single pg_dump. The limit is
MAX_ATTACHMENT_MB (default 1 GB). The workspace uploads files over 8 MB with the resumable API
(POST /api/v1/nodes/{id}/uploads, one request per 4 MB chunk, failed chunks retried), so the
proxy only has to accept requests a little over 4 MB for those. Downloads and exports stream
from the database; only the requested chunks are read for Range requests (video seeking).
Unfinished uploads are deleted after 24 hours.

Upgrading from a version that stored files in attachments.data moves the bytes into chunks
during the migration at startup (roughly 100+ MB/s; a few seconds per GB). The space of the
old column is reused by PostgreSQL over time; run VACUUM FULL attachments in a quiet moment
to give it back to the operating system at once.

Large files make the database bigger, so plan disk space and backup time accordingly. Storage
goes through a small interface (BlobStore, src/lib/server/content/blob.ts); an S3-compatible
backend could be added there later. It is not implemented.

Health checks

Endpoint Meaning
GET /healthz The process is up (liveness). Doesn't touch the database.
GET /readyz The database is reachable (readiness); returns 503 otherwise. Use this one for load-balancer health checks.

Shutdown

On SIGTERM the server stops accepting connections and waits up to SHUTDOWN_TIMEOUT seconds
for in-flight requests. It then closes the Postgres pool and the LISTEN connection and exits with
code 0. Open event streams (/api/v1/events) keep the server busy until SHUTDOWN_TIMEOUT
ends, so give the container a longer kill timeout. The compose files set
stop_grace_period: 30s. Background jobs are claimed with FOR UPDATE SKIP LOCKED. If a job is interrupted
mid-run, another replica re-queues it after 15 minutes.

Offline / PWA

The app can be installed as a PWA. Its service worker caches the app shell, and the pages a
signed-in user has read, for offline reading. See offline.md.

More

Oblish RSS feed Published with Oblish

Keyboard shortcuts

/
Search
[
Previous page
]
Next page
?
Show this list
Esc
Close