Skip to content
Go back

PrivateBin vs Hemmelig: Secrets That Burn

By KingPin 14 min read
PrivateBin vs Hemmelig: Secrets That Burn
Contents

Your Database Password Is Still in Slack

It’s 2 AM. Someone needs the staging database password, and you paste it into the team chat. It works. The chat also keeps it forever, indexes it for search, backs it up, and shows it to every new hire who joins the channel next year.

A self-destructing secret link fixes that. You paste the secret into a web form, get a URL, send the URL, and the secret deletes itself after one read or after a timer. Two self-hosted tools do this well: PrivateBin and Hemmelig. My verdict: run PrivateBin. Hemmelig has the nicer feature list, but its GitHub repository is archived, and that settles it for anything you plan to keep running for years.

The rest of this post shows why, with Compose files I started and poked at on 2 November 2026.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/self-hosting/privatebin-vs-hemmelig

How “The Server Knows Nothing” Actually Works

Both tools use the same trick. Learn it before you trust either one with a root password.

  1. Your browser generates a random key.
  2. Your browser encrypts the secret with that key using the Web Crypto API.
  3. Your browser sends only the ciphertext to the server.
  4. The key goes into the URL after the #, the fragment.
  5. Browsers never send the fragment to the server. It stays in the address bar and in JavaScript.

So the link looks like https://secrets.example.com/?70c6909d2443b493#<key>. The server stores the part before the # and a blob it cannot read. Your reverse proxy logs, your CDN, and your access logs never see the key.

The algorithms differ in the details. PrivateBin encrypts with AES-256 in GCM mode and derives the key with PBKDF2 at 100,000 iterations, per its privatebin.js. Hemmelig also uses AES-256-GCM through the Web Crypto API, derives keys with PBKDF2-SHA256 at 1,300,000 iterations, and generates a 32-character random key with nanoid, per its docs/encryption.md. Both are sound choices. Neither detail should decide your pick.

One trust gap remains, and PrivateBin’s README says it plainly. The server sends you the JavaScript that does the encrypting. A compromised server, or an admin who turns hostile, can ship code that steals the key. Zero knowledge protects you from a stolen database and from curious log readers. It does not protect you from the person running the box, so run it yourself or trust whoever does.

Deploying PrivateBin

PrivateBin is PHP, and the official privatebin/nginx-fpm-alpine image bundles nginx and PHP-FPM. The current release is 2.1.0, published 18 October 2026, and I tested the 2.1.0 tag. The image listens on port 8080, stores pastes in /srv/data, and reads an optional config from /srv/cfg/conf.php.

privatebin/docker-compose.yml
services:
privatebin:
image: privatebin/nginx-fpm-alpine:2.1.0
container_name: privatebin
restart: unless-stopped
read_only: true
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./conf.php:/srv/cfg/conf.php:ro
- privatebin-data:/srv/data
tmpfs:
- /tmp:nodev,noexec,mode=1777
- /run:nodev,exec,mode=1777
- /var/lib/nginx/tmp:nodev,noexec,mode=1777
volumes:
privatebin-data:

The read_only flag and the three tmpfs mounts come from the image’s own README, which says only /tmp, /run, /var/lib/nginx/tmp, and /srv/data need to be writable. A named volume dodges the ownership gotcha: the data directory must belong to UID 65534, and a fresh named volume inherits that from the image.

Now the config. PrivateBin ships cfg/conf.sample.php, and you only override what you want to change.

privatebin/conf.php
;<?php http_response_code(403); /*
; The line above keeps the file unreadable if a web server ever serves it as PHP.
; Everything not set here falls back to PrivateBin's built-in defaults.
[main]
; Pre-tick "Burn after reading" for every new paste.
burnafterreadingselected = true
; Comments need a readable paste, which fights burn-after-reading.
discussion = false
; Bytes per paste or comment. 10000000 is the value in the sample config.
sizelimit = 10000000
[expire]
; Must match a key in [expire_options] (defaults: 5min, 10min, 1hour, 1day,
; 1week, 1month, 1year, never).
default = "1day"
[traffic]
; Seconds between paste creations from one IP. 0 disables the limit.
limit = 10
; PrivateBin refuses to start without these two sections, even if you want the defaults.
[model]
class = Filesystem
[model_options]
dir = PATH "data"

That last gotcha cost me a 500 error on the first run. PrivateBin’s Configuration.php throws if a custom conf.php lacks [model] or [model_options], and the error only shows up in the container log. The first line, ;<?php http_response_code(403); /*, has to stay exactly as written, semicolon and all.

Start it and check it:

Terminal window
docker compose up -d
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/

You should get 200, and the page source shows “Burn after reading” pre-checked. I also pushed a paste through the JSON API (see the scripting section below), and it behaved.

A few defaults to know, all from conf.sample.php: file uploads are off (fileupload = false), the password option is on, and the traffic limiter allows one creation per IP every 10 seconds. The expiry menu runs from 5 minutes to 1 year, plus never. Storage defaults to plain files, but the same config can point at MySQL, PostgreSQL, SQLite, Google Cloud Storage, or S3-compatible storage.

Deploying Hemmelig

Hemmelig is a Node app (Hono on the server, a React front end) backed by SQLite. The image lives on Docker Hub as hemmeligapp/hemmelig, and I tested v7.6.1, the final release, published 25 September 2026.

A gotcha: the project README’s quick start says hemmelig/hemmelig:v7. That name does not exist on Docker Hub (the API returns “object not found”). The project’s own docs/docker.md and docker-compose.yml use hemmeligapp/hemmelig, and that one works.

hemmelig/docker-compose.yml
services:
hemmelig:
image: hemmeligapp/hemmelig:v7.6.1
container_name: hemmelig
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
volumes:
- hemmelig-database:/app/database
- hemmelig-uploads:/app/uploads
environment:
DATABASE_URL: file:/app/database/hemmelig.db
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?Set BETTER_AUTH_SECRET in .env}
BETTER_AUTH_URL: ${PUBLIC_URL:?Set PUBLIC_URL in .env}
HEMMELIG_BASE_URL: ${PUBLIC_URL}
NODE_ENV: production
# Set to "false" to stop new signups.
HEMMELIG_ALLOW_REGISTRATION: "true"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health/ready"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
hemmelig-database:
hemmelig-uploads:
hemmelig/.env.example
# Copy to .env, then fill in both values.
# Generate the secret with: openssl rand -base64 32
BETTER_AUTH_SECRET=
# The exact URL people type in the browser, no trailing slash.
PUBLIC_URL=https://secrets.example.com
Terminal window
cp .env.example .env
sed -i "s|^BETTER_AUTH_SECRET=.*|BETTER_AUTH_SECRET=$(openssl rand -base64 32)|" .env
docker compose up -d

Three variables are required: DATABASE_URL, BETTER_AUTH_SECRET, and BETTER_AUTH_URL. The Compose file refuses to start if the secret is missing, so you cannot forget it. The container went healthy in under 20 seconds on my machine, and the front page returned 200.

The docs list SQLite as the database, so there is no Postgres to run. That keeps the stack small, but it also means your backup plan is “copy one file plus the uploads folder.”

Hemmelig’s defaults are more generous than PrivateBin’s. It accepts anonymous secret creation out of the box (the requireRegisteredUser setting is off unless you flip it), lets you set a view count from 1 to 9999, and offers expiry from 5 minutes up to 28 days. The default is one view, per api/config.ts. Its rate limiter defaults to 100 requests per 60 seconds, tunable with HEMMELIG_RATE_LIMIT_REQUESTS and HEMMELIG_RATE_LIMIT_WINDOW.

Side by Side

PrivateBin 2.1.0Hemmelig v7.6.1
Repo statusActive (last push 30 Oct 2026)Archived, read-only
LicenseZlib/libpng, plus bundled librariesO’Saasy (no competing hosted service)
StackPHP, nginxNode, SQLite
Image size on disk170 MB1.1 GB
Idle memory (rough)about 75 MiBabout 250 MiB
EncryptionAES-256-GCM, PBKDF2 100kAES-256-GCM, PBKDF2 1.3M
Expiry choices5 min to 1 year, or never5 min to 28 days
Read limitBurn after reading (one read)1 to 9999 views, or none
PasswordYesYes
File uploadsOff by default, config switchOn by default, for signed-in users
IP restrictionCreator allow-listPer-secret IP ranges
Discussion threadsYesNo
StorageFiles, SQL, GCS, S3SQLite
APIJSON APIREST API with keys, CLI, webhooks

The size and memory numbers come from docker image inspect and docker stats on idle containers. Treat them as rough shape, not a benchmark. Hemmelig’s memory moved between 240 and 320 MiB across samples, while PrivateBin stayed between 73 and 88 MiB.

A note on the license row. Hemmelig’s O’Saasy license lets you use, modify, and self-host the code, but forbids offering it to third parties as a competing hosted service. Running it for your own team is fine. Reselling it is not. PrivateBin’s Zlib/libpng license has no such limit, though the repository also bundles libraries under other licenses, as its LICENSE.md lists.

HTTPS Is Not Optional

Both tools depend on crypto.subtle, and MDN documents that API as available only in secure contexts, meaning HTTPS. Put the app behind a reverse proxy that terminates TLS, such as Caddy, Traefik, or nginx. If you serve either tool over plain HTTP on a LAN address, expect the browser to refuse to encrypt.

PrivateBin has a second reason to care. Its README says the instance “has to be used over HTTPS” because over plain HTTP you would also have to trust your internet provider and every network hop. The config sample even carries an httpwarning option that shows an insecure-connection banner, and it marks switching it off as strongly discouraged.

Two proxy details bite. For Hemmelig, set BETTER_AUTH_URL and HEMMELIG_BASE_URL to the exact public URL, and if you want the rate limiter to see real client IPs, list your proxy in HEMMELIG_TRUSTED_PROXIES. For PrivateBin, the [traffic] section has a header key (for example X_FORWARDED_FOR) that tells the limiter where the visitor IP lives. Skip it and every user looks like your proxy.

Sharp Edges You Will Hit

You paste a burn-after-reading link into Slack. Slack fetches the URL to build a preview. The secret is gone before your coworker clicks. That is a failure you can hit on day one.

Both tools defend against it, in different ways.

PrivateBin marks burn-after-reading links with a #- prefix on the key. When the page sees that prefix, it shows a modal asking “This secret message can only be displayed once. Would you like to see it now?” and only fetches the paste after you click. I confirmed the other half with a test: a plain HTTP GET of the paste page returned 200, and the first JSON read afterward still succeeded. A second read returned “Document does not exist, has expired or has been deleted.”

Hemmelig does the equivalent through its page. The secret page renders locked placeholder bars, and the view is consumed only when the reader submits the unlock form, which calls handleViewSecret in SecretPage.tsx. In my test, loading /s/<id> returned 200, and the first reveal call after that still succeeded. The second reveal returned 404.

I did not test a previewer that executes JavaScript, such as a headless browser. A bot that clicks buttons would defeat either design. If you share links in chat, add a password and send the password through a different channel. Hemmelig’s docs describe this flow: with a password set, the URL carries no #<key> at all.

Fragments, Logs, and Browser History

The fragment never reaches your server, so your own logs are clean. The full URL still sits in the sender’s chat history, the recipient’s browser history, and any bookmark. Hemmelig’s encryption doc says so directly. A one-read link that has already burned leaves a useless URL behind, which is the point. A link with a 28-day expiry and no read limit leaves a live secret in two histories. Use the shortest expiry you can stand.

Backups Are Safe and Useless

Backing up PrivateBin’s data volume or Hemmelig’s SQLite file gets you ciphertext. Without the URL fragments, nobody can read any of it, including you. That is good for compliance conversations. It also means a restore brings back pastes nobody can open, so do not treat these tools as storage. They are a mailbox with a shredder.

Upgrading Hemmelig Is Over

Hemmelig v7 was a full rewrite, and its docs/upgrade.md says v6 data cannot migrate. That is history now. The relevant fact in November 2026 is that v7.6.1 is the last release. The current README banner on the default branch says the repository is archived, gets no more fixes, features, or releases, and invites forks. The hosted service at hemmelig.app continues separately.

For a tool whose entire job is handling passwords and API keys, “no more fixes” is a heavy sentence. A vulnerability in the front-end code would sit there unpatched.

Doing It From a Script

PrivateBin accepts a JSON POST with the X-Requested-With: JSONHttpRequest header. The body holds adata (the cipher parameters, the formatter, the discussion flag, and the burn flag), meta.expire, and the ciphertext in ct. Your script must do the AES-GCM step itself, because the server will not do it for you. I tested creation and reading with placeholder ciphertext, which proves the storage and burn behavior but not a full encrypted round trip.

Hemmelig takes the same approach. POST /api/secrets accepts salt, secret (the encrypted bytes), expiresAt in seconds, and views. Anonymous creation worked on my default instance, and the response held an id and a deleteToken. Its CLI does the encryption for you, for example hemmelig "API key: sk-1234" -e 7d -v 3 per its README. The CLI is a real convenience for CI jobs, and PrivateBin has no equivalent in the main project.

The Verdict: Pick the Boring One

Use PrivateBin if you want a secrets tool you can forget about. It’s small, it’s maintained (a release on 18 October and a push on 30 October), it runs on files, and the failure modes are known. Its burn-after-reading confirmation handles the Slack previewer problem out of the box.

Use Hemmelig only if you need something PrivateBin lacks: a view count above one, per-secret IP ranges, signed-in file uploads, or the CLI. Pin v7.6.1, keep it behind a login or a VPN, and plan on swapping it out or maintaining a fork. Running an archived app is like parking a forklift in the garage because it moves couches nicely. It works great until the day something breaks and nobody ships the part.

For most home labs, the choice is easy. Install PrivateBin, put it behind HTTPS, set a short default expiry, and tell your team to stop pasting passwords into chat. Your 2 AM self will thank you.

Common Questions

Is PrivateBin end-to-end encrypted?

Yes, in the browser-to-browser sense. PrivateBin encrypts each paste in the sender’s browser with AES-256-GCM and keeps the key in the URL fragment, so the server stores only ciphertext. The catch is that the server delivers the JavaScript, so you must trust whoever operates the instance.

Does Hemmelig need a separate database server?

No. Hemmelig v7 stores everything in a SQLite file, set through DATABASE_URL, plus a folder for uploaded files. The Docker Compose file needs only two volumes and one container. You can back up the whole instance by copying those two paths while the container is stopped.

Can I run PrivateBin or Hemmelig on a Raspberry Pi?

Yes, both run on a Raspberry Pi 4 or newer. The PrivateBin image is about 170 MB and used roughly 75 MiB of RAM idle in my test. Hemmelig publishes linux/arm64 images, but the Hemmelig image is about 1.1 GB on disk and used roughly 250 MiB idle.

Should I move off Hemmelig now that the repository is archived?

Yes, if the instance stores real credentials and faces the internet. The repository is read-only and receives no fixes, so any vulnerability stays open. Move to PrivateBin, or keep Hemmelig only behind a VPN or login while you plan the switch. Existing secrets are short-lived anyway, so migration means just starting fresh.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Shlink vs YOURLS: Self-Hosted Short Links

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts