Workload · Sharing secrets with PrivateBin

Self-hosted PrivateBin: an encrypted pastebin on a server you control

To host PrivateBin yourself, run the project's Docker image, privatebin/nginx-fpm-alpine, on a server of your own with HTTPS in front; each browser then scrambles a note before sending it, so your server keeps the notes only in a form it cannot read. This guide sets that up on Ubuntu 24.04, then helps you choose settings, decide whether strangers may use it, and keep copies you can restore.

The short version
  1. Your browser encrypts; the link's key never reaches the server
  2. The project's own image, locked read-only
  3. One folder and one settings file to back up
  4. No accounts to manage and no admin login
  5. Free software under the permissive zlib/libpng licence

Why run your own copy?

Because the code that does the encrypting comes from whoever runs the site. The FAQ admits that browser-side encryption means trusting the operator not to deliver tampered scripts, and its first tip for picking a server is to host one yourself. Then the colleagues you send passwords, logs or config snippets to only need to trust you.

It is a light job, too. The install guide wants PHP 7.4 or newer with zlib, some writable disk and a browser with JavaScript, and the container carries all of that. The docs name no memory or processor size; the encryption itself runs in each visitor's browser.

What stays secret, and what does not

Notes are locked with AES (256-bit keys, Galois Counter mode) before they leave your browser. The key sits in the link after the # sign, and the FAQ says neither it nor any password is ever stored or sent. Text, attachments with their names, comments and commenters' nicknames are encrypted. Left readable: when something was posted, its expiry, its format, whether comments are allowed, and the small IP-based avatar next to a named commenter.

The README is honest about the limits, so know them before you rely on it:

  • A link with no password works for everybody who sees it. Before posting one where others can read it, add a long password and pass that along by another encrypted route.
  • Plain HTTP would let someone change the scripts in transit, so HTTPS is required, ideally with HSTS.
  • Server access logs may still show who opened a note first, and an operator can be compelled to hand them over.
  • A hacked or pressured server could serve code that grabs keys, though notes already stored stay unreadable until someone opens a link there.
  • Public link shorteners see the key. Keep shortening in-house, for example with your own YOURLS.

The server that fits

Choose a small machine from our dedicated servers and let it do just this. It is entirely yours, with root access and an unmetered port, so a busy week of shared files never becomes a bandwidth bill. Ready servers come online within 30 minutes of approval; a machine built to order takes hours instead.

  • Want to undo a firewall slip on your own? Rack servers and blades have a console screen and power button, and so do two cartridges, the m510 and m710x. The remaining cartridges can only be power-cycled.
  • Pass over the X-Gene cartridge (ARM): its system choices end at Ubuntu 18.04, while this guide needs 24.04 and a recent Docker Engine.
  • You get one IPv4 address, and IPv6 is free, so the site can answer on both.
  • You manage the server; we keep the hardware and the network running.

Up and running in three steps

Install Docker Engine plus its compose plugin with Docker's guide for Ubuntu. Then paste this as root. It turns the firewall on with SSH allowed, checks your Docker version, downloads release 2.1.0's sample settings, and starts PrivateBin on a port only the server can reach.

# run as root on Ubuntu 24.04; Docker Engine + compose plugin already installed
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH && ufw --force enable && ufw allow 80,443/tcp
ENGINE=$(docker version --format '{{.Server.Version}}')
DIR=/opt/privatebin
if [ "${ENGINE%%.*}" -ge 28 ] 2>/dev/null && [ ! -e "$DIR" ]; then
  install -d -o 65534 -g 82 -m 750 "$DIR/data" &&
  curl -fsSL https://raw.githubusercontent.com/PrivateBin/PrivateBin/2.1.0/cfg/conf.sample.php -o "$DIR/conf.php" &&
  sed -i 's/^; header = "X_FORWARDED_FOR"/header = "X_FORWARDED_FOR"/' "$DIR/conf.php" &&
  grep -q '^header = "X_FORWARDED_FOR"' "$DIR/conf.php" &&
  chown root:82 "$DIR/conf.php" && chmod 640 "$DIR/conf.php" &&
  cat > "$DIR/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:
      - /opt/privatebin/data:/srv/data
      - /opt/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 "$DIR/compose.yaml" up -d && echo "PrivateBin is running on 127.0.0.1:8080" || echo "Something failed partway: check $DIR, delete it, then run this again"
else
  echo "Stopped: Docker Engine is $ENGINE (need 28+) or $DIR already exists"
fi
  1. About the version check: the image README publishes 8080 on every address. Docker routes published IPv4 traffic around ufw's rules, and IPv6 as well when IPv6 networking is enabled in Docker itself (if not, docker-proxy handles IPv6 and ufw filters that). Tying the port to 127.0.0.1 keeps it private, which only Engine 28 or later guarantees, so older versions are refused.
  2. About re-runs: if /opt/privatebin exists already, the block leaves it alone, so your edited settings survive.
  3. Then add your domain and HTTPS with the next block. Create the DNS records first, including AAAA for IPv6. Caddy, packaged in Ubuntu 24.04, gets and renews the certificate. Since it runs directly on the server, the 80 and 443 rule from step one is what lets people in on IPv4 and IPv6.
  4. Visit https://paste.example.org, write a test note, and open its link in a private window. With no admin account to create, you are live as soon as HTTPS answers.
# run as root after paste.example.org points at your server
apt update && apt install -y caddy
cat > /etc/caddy/Caddyfile <<'EOF'
paste.example.org {
	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

Where we depart from the docker run example in the README, all our choices: the loopback port, version 2.1.0 pinned instead of latest, fixed folders under /opt/privatebin, the compose format, and capped logs. Kept as published: read-only mode, tmpfs for /tmp and /run, the data folder mounted at /srv/data and owned by UID 65534 and GID 82, the settings file attached read-only at /srv/cfg/conf.php, and restart always. The sample settings are downloaded because the Dockerfile strips them from the image.

The one setting the block flips, header, exists for proxies. PrivateBin limits how often a single address may post, but behind Caddy every visitor would share one address. With it, the limiter reads X-Forwarded-For, which your Caddyfile overwrites with the true visitor address, as the configuration wiki requires.

Choosing your settings

Settings live in /opt/privatebin/conf.php; restart afterwards with docker compose -f /opt/privatebin/compose.yaml restart. Common choices, with conf.sample.php defaults:

  • Lifetime. One week is preselected; choices go from 5 minutes up to a year, plus never. Removing never makes every new note expire.
  • Burn after reading. Off unless you set burnafterreadingselected = true; the note vanishes after its first opening.
  • Files. Off until fileupload = true. Each note including its attachment must fit sizelimit. The sources differ on its default: conf.sample.php sets 10,000,000 bytes (its comment says 10 Megabytes), whereas 10 MiB (10,485,760) is the figure given in the image README and on the configuration wiki. Separately, the image's nginx.conf and 00-docker.ini cap requests at 15M. For bigger files, raise sizelimit, then mount one extra file into /etc/php/conf.d/ setting post_max_size and upload_max_filesize, plus a memory_limit double the upload size, as the wiki advises. For nginx, copy the image's site.conf, add a client_max_body_size line inside its server { } block, and mount your copy over /etc/nginx/http.d/site.conf. Don't drop in a new http.d file with just that line: the image's nginx.conf sets the same directive in its http block, which pulls in every http.d/*.conf file, and nginx refuses to start on a duplicate.
  • Storage. Plain files (Filesystem) are the default and what the install guide suggests for one server. A PDO database suits heavy load; MariaDB/MySQL, PostgreSQL and SQLite were tested, with SQLite discouraged in production.

With release 2.1.0, leave comments, passwords, e-mail sharing and QR codes switched on. The CHANGELOG lists a fix for the coming 2.1.1: note creation breaks when any of them is off.

cspheader sets the Content Security Policy. By default notes cannot pull images from other sites; conf.sample.php shows the variant to use if uploads are on and you want PDF previews. Make sure no proxy or CDN rewrites the JavaScript: per the FAQ, every script is checked against an integrity hash, and a changed one leaves the page stuck loading.

If you open it to the public

You may, and the README states the upside: you cannot read what people post, yet you can delete any note when asked or required. To keep it to your team, list your office or VPN ranges in creators; only those can post, while reading stays open to link holders.

  • A reported note. Its ID sits in the link between ? and #. Remove it with cd /opt/privatebin && docker compose exec privatebin administration -d PASTE_ID.
  • Floods. By default an address may create one note every 10 seconds. Views are not limited; the configuration wiki suggests limiting those in your web server or with an application firewall.
  • Posting behind a password. The FAQ describes requiring basic auth only for POST requests at the web server, while warning that such setups can still be abused for phishing.
  • Search engines. A robots.txt ships in the web root, asking crawlers to keep out so links carrying keys are not indexed.
  • Logs. The image writes each visit's forwarded address to docker logs. Our compose file keeps three files of 10 MB at most; keep only what you need.

Your hosting terms cover what the server is used for, so read them before inviting strangers.

Keeping copies, and getting them back

Everything worth saving is in /opt/privatebin: notes and comments in data/, data/salt.php, the server-wide salt PrivateBin mixes into hashed visitor IPs for rate limiting and into comment avatars (lib/Persistence/ServerSalt.php and TrafficLimiter.php; in 2.1.0 delete tokens carry a per-note salt instead, per lib/Model/Paste.php), the rate-limit and purge records, conf.php and compose.yaml. The script below also saves your Caddyfile, stops PrivateBin for the copy and starts it again afterwards, and prunes old archives only after the new one checks out.

# file: /root/pb-backup.sh  (our script; run as root with: bash /root/pb-backup.sh)
umask 077
OUT=/var/backups/privatebin; ARC=$OUT/pb-$(date +%F).tar.gz
mkdir -p -m 700 "$OUT"
docker compose -f /opt/privatebin/compose.yaml stop
tar -czf "$ARC" /opt/privatebin /etc/caddy/Caddyfile; status=$?
docker compose -f /opt/privatebin/compose.yaml start
if [ "$status" -eq 0 ] && tar -tzf "$ARC" > /dev/null; then
  echo "ok $ARC"
  find "$OUT" -name 'pb-*.tar.gz' -mtime +14 -delete
else
  echo "backup failed, nothing pruned"; rm -f "$ARC"; exit 1
fi

Try it once by hand; after that, schedule it in root's crontab: 20 2 * * * bash /root/pb-backup.sh > /var/backups/privatebin/last.log 2>&1. Archives on the same machine die with it, so copy them elsewhere with rsync -a (without --delete, which would mirror a wiped folder) and tidy old ones there with a separate job. To restore here, work as root, or start each command with sudo:

  1. Test the archive: tar -tzf /var/backups/privatebin/pb-DATE.tar.gz > /dev/null && echo ok. Continue only on ok.
  2. Stop PrivateBin: docker compose -f /opt/privatebin/compose.yaml down.
  3. Park the current folder, provided no older copy is in the way: [ ! -e /opt/privatebin.previous ] && mv /opt/privatebin /opt/privatebin.previous && test -d /opt/privatebin.previous/data && echo ok. Continue only on ok.
  4. Unpack, then restore ownership: tar -xzf /var/backups/privatebin/pb-DATE.tar.gz -C / opt/privatebin && chown -R 65534:82 /opt/privatebin/data && chown root:82 /opt/privatebin/conf.php && chmod 640 /opt/privatebin/conf.php.
  5. Relaunch: docker compose -f /opt/privatebin/compose.yaml up -d. A link that worked before should open again.
  6. Not right? down once more, then mv /opt/privatebin /opt/privatebin.failed && mv /opt/privatebin.previous /opt/privatebin, and up -d puts you back where you began.
  7. All good? Delete the parked copy: rm -rf /opt/privatebin.previous.

Staying current, and the licence

SECURITY.md says only the newest release receives security fixes, so follow releases. The image README describes stable, nightly and latest as tags that move on their own. A version tag like 2.1.0 holds the PrivateBin release steady but gets rebuilt for important Alpine security fixes; only the longer tags such as 2.1.0-alpine3.24 never change. This guide uses 2.1.0, so a pull also picks up those Alpine fixes. For each new release: back up, read the CHANGELOG, compare the fresh conf.sample.php with your conf.php, write the new version into compose.yaml, and finally run docker compose -f /opt/privatebin/compose.yaml pull to fetch it and docker compose -f /opt/privatebin/compose.yaml up -d to recreate the container.

LICENSE.md places PrivateBin's own code under the zlib/libpng licence and also carries the texts for bundled libraries under BSD 3-clause, Apache and MIT and CC-BY for the logos. Its introduction still mentions rawinflate under GPLv2, a library we did not find in the 2.1.0 js folder. How we read it, not legal advice: these permissive terms let you run and change PrivateBin without publishing your source; give copies to others and you keep the notices and licence texts, and mark a modified version as modified.

Teams that share secrets usually share notes as well: HedgeDoc for collaborative notes fits the same kind of box, and Nginx Proxy Manager puts several apps behind one front door; if it fronts PrivateBin as well, it must replace X-Forwarded-For with the real client address, not add to a header the visitor supplied.

Questions people ask

Do I need a database to run PrivateBin?
No. Out of the box it saves notes as files in its data folder, which the install guide recommends for most single-server setups. A database is there for heavy load or when files cannot be kept locally.
Could I, as the operator, read my users' notes?
Not from what is stored, because the key lives in the link after the # and never reaches your server. Only by serving altered scripts could you get at it, which is exactly why the project tells people to stick to instances they trust.
Is it safe to share a PrivateBin link in a public chat?
Only with a password. Otherwise anyone who sees the link reads the note. Set a long password and send it by a separate encrypted route, or tick burn after reading so the note disappears after one view.
Does PrivateBin have an admin panel?
No. There are no user accounts and no admin login. You steer it with conf.php and the administration script inside the container, which deletes a note, purges expired ones and prints statistics.
Which Docker image should I use?
The project's privatebin/nginx-fpm-alpine works with every storage option. Slimmer single-backend variants, such as privatebin/fs for file storage, are published on Docker Hub and the GitHub registry too.

Host your own encrypted pastebin

Choose a small server that offers Ubuntu 24.04. A ready one is handed over within 30 minutes of approval, and the setup above takes well under an hour after that.

Get a server for PrivateBin

Read next