Use case · Encrypted pastebin

PrivateBin self hosted on a dedicated server: the Docker image, conf.php and HTTPS

Self-hosted PrivateBin is a PHP pastebin that stores each document's content only as ciphertext: the browser encrypts it with 256-bit AES-GCM, and the key rides in the link after the #, which the server never receives. Below, the project's image, privatebin/nginx-fpm-alpine, runs read-only on Ubuntu 24.04 behind Caddy, followed by the conf.php settings that matter, public instances, backups, updates and the licence.

See the machines

What the server knows, and what it does not

Per the README, the server has zero knowledge of stored data. The FAQ lists the decryption key and any password as neither stored nor transmitted. Its split of the rest:

Encrypted in the browserVisible to the server
Document textCreation time of document or comment
Attached files and their namesExpiry time, if chosen
Comments and nicknamesFormat: plain text, source code or Markdown
Discussion on or off; the IP-based icon beside a nickname

The README's list of what PrivateBin does not provide:

  • Readers trust the operator not to inject code. A compromised or coerced server can ship scripts that log keys, and the docs say not to open an instance you think is compromised.
  • HTTPS is required, with HSTS on top; over plain HTTP anyone on the path can swap the scripts.
  • A link without a password opens for whoever holds it. Posting one publicly calls for a strong password, shared privately over an end-to-end encrypted channel.
  • Access logs can still reveal who fetched a document first, and can be demanded from the operator.
  • A public URL shortener sees the whole link, key included; the FAQ advises a self-hosted one, such as YOURLS on a dedicated server.

Machine requirements

Installation.md asks for PHP 7.4+ with the zlib extension, writable disk and a JavaScript browser; GD and OPcache are optional. No RAM or core figure is published, and the image bundles PHP and nginx. Our reading: with the cipher work in visitors' browsers, the host mostly stores and serves files, so this is one service on one box, the job our dedicated server range gives the Moonshot cartridges.

  • Architecture: the 2.1.0 image is built for amd64, arm64 and more (Docker Hub tag list). The X-Gene ARM cartridge is still excluded: its systems stop at Ubuntu 18.04, and this setup needs 24.04 with Docker Engine 28+.
  • Console: every racked single-, dual- and quad-socket server has an out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC), as do the m510 and m710x cartridges; the rest of the cartridge range has remote power but no screen.
  • Network: an unmetered port and one IPv4. IPv6 is not in the default build; it is added at checkout at no charge.
  • Delivery: racked lines go live about 30 minutes after payment approval; built-to-order lines take 4 to 24 hours.
  • Storage: Filesystem is the default backend and Installation.md's recommendation for one host; on our reading a single SSD will do.

Firewall, conf.php and the container

The image README uses -p 8080:8080. With no address, Docker publishes on 0.0.0.0 and [::]; IPv4 traffic then skips ufw's rules, and IPv6 too when Docker's own IPv6 networking is enabled (otherwise docker-proxy carries [::], and ufw filters it). We bind 127.0.0.1, which is local-only from Engine 28, so the block checks the version first. Prerequisite: Docker Engine with the compose plugin, installed per Docker's Ubuntu instructions.

# as root on Ubuntu 24.04 with Docker Engine and its compose plugin installed
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH && ufw --force enable && ufw allow 80,443/tcp
V=$(docker version --format '{{.Server.Version}}'); echo "Engine $V"
P=/srv/privatebin
if [ "${V%%.*}" -ge 28 ] 2>/dev/null && [ ! -e $P ]; then
  install -d -o 65534 -g 82 -m 750 $P/data &&
  curl -fsSL -o $P/conf.php https://raw.githubusercontent.com/PrivateBin/PrivateBin/2.1.0/cfg/conf.sample.php &&
  sed -i 's/^; header = "X_FORWARDED_FOR"/header = "X_FORWARDED_FOR"/' $P/conf.php &&
  grep -q '^header = "X_FORWARDED_FOR"' $P/conf.php &&
  chown root:82 $P/conf.php && chmod 640 $P/conf.php &&
  cat > $P/compose.yaml <<'EOF' &&
services:
  privatebin:
    image: privatebin/nginx-fpm-alpine:2.1.0
    restart: always
    read_only: true
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - /srv/privatebin/data:/srv/data
      - /srv/privatebin/conf.php:/srv/cfg/conf.php:ro
    tmpfs:
      - /tmp:nodev,noexec,mode=1777
      - /run:nodev,exec,mode=1777
    logging:
      options: { max-size: "10m", max-file: "3" }
EOF
  docker compose -f $P/compose.yaml up -d && echo "started" || echo "FAILED partway: inspect $P, remove it, then re-run"
else
  echo "STOP: Engine older than 28, or $P already exists"
fi

Our changes to the README's docker run line: loopback port, tag 2.1.0 instead of latest, fixed paths under /srv/privatebin, compose format, and a log size cap. Unchanged: read-only root, both tmpfs mounts, the /srv/data volume, conf.php mounted read-only into /srv/cfg, restart always.

  • The data folder must be owned by UID 65534, GID 82 (image README); conf.php is readable by group 82 only.
  • A second run stops at the existence check, so an edited conf.php is never overwritten.
  • The Dockerfile deletes conf.sample.php from the image, hence the download from the 2.1.0 tag.
  • header makes the traffic limiter take the client address from X-Forwarded-For (TrafficLimiter.php, only when that header is present). The configuration wiki says to set it only behind a proxy that overwrites the header, which Caddy does below.
  • Local check: curl -sI http://127.0.0.1:8080/ should return 200.

HTTPS, HSTS and the real client address

Inside the container nginx speaks only HTTP on 8080; TLS belongs to a proxy on the host, per the image README. Caddy from the Ubuntu 24.04 archive gets and renews the certificate. It is a host process, so the 80/443 ufw rule above is what admits visitors and certificate checks over IPv4 and IPv6. PrivateBin has no login, so nothing needs creating before it goes public.

# as root, once paste.example.com resolves to this server (A, plus AAAA if you added IPv6)
apt update && apt install -y caddy
cat > /etc/caddy/Caddyfile <<'EOF'
paste.example.com {
	header Strict-Transport-Security "max-age=31536000"
	reverse_proxy 127.0.0.1:8080 {
		header_up X-Forwarded-For {remote_host}
	}
}
EOF
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile && systemctl reload caddy
  • header_up replaces X-Forwarded-For with the connecting address, so nobody can forge it to slip the rate limit.
  • HSTS: the README asks for it and the image's site.conf leaves its own HSTS line commented out. One year of max-age binds the name to HTTPS that long; start lower if unsure.
  • CSP comes from cspheader in conf.php. The default blocks third-party images in documents; conf.sample.php comments carry a variant that permits PDF previews once uploads are on.
  • Keep proxies and CDNs from rewriting JavaScript: scripts carry Subresource Integrity hashes, and the FAQ says altered files leave the page on Loading. The image already sends Cache-Control no-transform.
  • Already proxying other apps? Nginx Proxy Manager can front it instead, provided it overwrites X-Forwarded-For with the client's address rather than appending to whatever the visitor sent.

conf.php settings that change behaviour

Edit /srv/privatebin/conf.php, then docker compose -f /srv/privatebin/compose.yaml restart. Defaults are conf.sample.php's for 2.1.0.

SettingDefaultEffect
[expire] default1weekOptions run 5min to 1year, plus never; drop never to force expiry (new documents only).
burnafterreadingselectedfalsePreticks burn after reading.
discussiontrueComment threads under documents.
passwordtrueOptional password on top of the link key.
fileuploadfalseAttachments with image, media and PDF preview.
sizelimit10000000 bytesPer document or comment, attachment counted after compression and encryption.
[traffic] limit10 sGap between creations per IP; creators limits creation to listed IPs or subnets.
[model] classFilesystemOr Database over PDO, tested on SQLite, MariaDB/MySQL and PostgreSQL; SQLite not advised for production.

On 2.1.0, keep discussion, password, e-mail and QR code enabled: the CHANGELOG's unreleased 2.1.1 entry fixes creation failing with a JavaScript error when one is off.

The sources disagree on the size cap. conf.sample.php ships 10000000 bytes and calls it 10 Megabytes; the configuration wiki and the image README both say 10 MiB (10485760 bytes). Inside the image, nginx.conf sets client_max_body_size and 00-docker.ini sets upload_max_filesize and post_max_size, all to 15M. To go higher, raise sizelimit, then mount a file into /etc/php/conf.d/ with upload_max_filesize, post_max_size and memory_limit at twice the upload size (the wiki's rule). For nginx, mount your own copy of the image's site.conf over /etc/nginx/http.d/site.conf with client_max_body_size added inside its server block. A separate http.d file holding only that directive fails: nginx.conf already sets it at http level and includes http.d/*.conf in that same http block, so nginx rejects the duplicate and will not start.

Running an instance strangers can use

The README's pitch to operators is plausible deniability: content is unreadable to you, yet any document can be deleted on request or order. A document is stored under its ID, the part of the link between ? and #. What the docs give an open instance:

  • Deletion: docker compose -f /srv/privatebin/compose.yaml exec -T privatebin administration --delete PASTE_ID; the same script takes --purge and --statistics.
  • Write control: creators in conf.php, or basic auth on POST at the proxy so reading stays open; the FAQ cautions that read-only setups can still aid phishing.
  • Throttling: the creation limit leaves views alone; for those the configuration wiki points to the web server or an application firewall.
  • Crawlers: the bundled robots.txt asks bots not to index, so link keys do not leak into search.
  • Logs: the image's nginx log format includes X-Forwarded-For, so visitor addresses reach docker logs. Set your retention deliberately.

A public paste service also answers to the terms you accepted with the machine's operator; check them before announcing it.

Backups and a live restore

All state sits in /srv/privatebin: documents in data/, salt.php (the installation's server salt, used to hash visitor IPs for the traffic limiter and the discussion icons, per ServerSalt.php and TrafficLimiter.php; in 2.1.0 each document's delete token uses a salt of its own, per Paste.php), the traffic and purge limiter files, conf.php and compose.yaml. With the Caddyfile that is everything. The script stops the container, runs tar, then starts it again, so no file is caught mid-write.

# file: /root/privatebin-backup.sh  (ours; run as root: bash /root/privatebin-backup.sh)
umask 077
B=/root/privatebin-backups; F=$B/privatebin-$(date +%F).tar.gz
mkdir -p -m 700 "$B"
docker compose -f /srv/privatebin/compose.yaml stop
tar -czf "$F" /srv/privatebin /etc/caddy/Caddyfile; rc=$?
docker compose -f /srv/privatebin/compose.yaml start
if [ "$rc" -eq 0 ] && tar -tzf "$F" > /dev/null; then
  echo "ok $F"
  find "$B" -name 'privatebin-*.tar.gz' -mtime +14 -delete
else
  echo "backup FAILED"; rm -f "$F"; exit 1
fi

Run it by hand once, then from root's crontab: 15 3 * * * bash /root/privatebin-backup.sh > /root/privatebin-backups/last.log 2>&1. Ship the archives off this box yourself with plain rsync -a, never --delete, and prune them there with their own job. Live restore, as root (or put sudo before each command):

  1. Verify: tar -tzf /root/privatebin-backups/privatebin-DATE.tar.gz > /dev/null && echo ok. No ok, no restore.
  2. Stop: docker compose -f /srv/privatebin/compose.yaml down.
  3. Set current state aside, refusing if a previous copy remains: [ ! -e /srv/privatebin.previous ] && mv /srv/privatebin /srv/privatebin.previous && ls /srv/privatebin.previous/data > /dev/null && echo ok. No ok, stop here.
  4. Unpack and reset ownership: tar -xzf /root/privatebin-backups/privatebin-DATE.tar.gz -C / srv/privatebin && chown -R 65534:82 /srv/privatebin/data && chown root:82 /srv/privatebin/conf.php && chmod 640 /srv/privatebin/conf.php.
  5. Start the restored copy: docker compose -f /srv/privatebin/compose.yaml up -d, then test a known link.
  6. Rollback: down, mv /srv/privatebin /srv/privatebin.failed && mv /srv/privatebin.previous /srv/privatebin, up -d.
  7. When satisfied: rm -rf /srv/privatebin.previous.

Updates and the licence

SECURITY.md supports only the current release, 2.1.0 today. Per the image README, stable, nightly and latest move; a numeric tag such as 2.1.0 keeps the PrivateBin release but is rebuilt when Alpine ships important security fixes, and only the longer 2.1.0-… tags never change. We pin 2.1.0, so a pull still brings those Alpine fixes. To update: back up, read the CHANGELOG, diff the new conf.sample.php against yours, change the tag in compose.yaml, then docker compose -f /srv/privatebin/compose.yaml pull && docker compose -f /srv/privatebin/compose.yaml up -d. On the Database backend, Installation.md says self-managed table updates need CREATE, INDEX and ALTER on the database plus INSERT and UPDATE on the config table.

LICENSE.md puts PrivateBin's own PHP and JavaScript, first written by Sébastien Sauvage in 2012, under zlib/libpng, and collects BSD 3-clause, MIT and Apache terms for bundled libraries plus CC-BY for the logos. Its intro still names rawinflate under GPLv2, absent from the 2.1.0 js folder (our check). Our general reading, not legal advice: permissive terms impose no duty to publish source when you run or modify it; passing copies on means keeping notices and licence texts, and zlib requires altered versions to be plainly marked.

Questions

Can the person running a PrivateBin server read my documents?

Not from storage: the server holds ciphertext and never receives the key after the #. An operator could serve modified JavaScript that captures keys, which is why the project says to use trusted instances over HTTPS only. Running your own removes that third party.

Is a database required?

No. Filesystem is the default and Installation.md recommends it for most single-host sites. The Database model over PDO suits high load or setups that cannot store files locally.

Why run the container read-only?

The image README says it narrows the attack surface: an exploit in nginx or php-fpm cannot overwrite files inside the container. Only /tmp, /run, /var/lib/nginx/tmp and /srv/data stay writable.

How do I remove a reported document?

Copy its ID from the link, between ? and #, and pass it to the image's administration script with --delete. The creator can also use the delete link shown when the document was made.

A machine for your own pastebin

Pick a line with a console, install Ubuntu 24.04, and the three blocks above have PrivateBin answering on HTTPS.

See the machines

Related on Server Room

Other use cases