// Share secrets securely. The server never knows.
// WHAT WE'RE BUILDING
In this tutorial, you'll create your own encrypted pastebin - a tool like PrivateBin where you can share sensitive text securely. The key feature is client-side encryption: your text is encrypted in your browser before it's ever sent to the server. The server stores only encrypted data and never sees your plaintext.
// WHY THIS MATTERS
Regular pastebins store your text in plain text on their servers. That means anyone who hacks the server, or anyone with access to the database, can read your pastes. With client-side encryption, even if the server is compromised, your secrets remain safe. Only someone with the password (or the encryption key in the URL) can decrypt the content.
This project combines everything you've learned and adds some new concepts:
Here's the magic of client-side encryption:
The server never sees the encryption key. It's stored in the URL fragment (after the #), which is never sent to the server. This is the same technique password managers use for secure sharing.
Prerequisites
Before starting, make sure you've completed:
Your pastebin will use three Docker containers:
Unlike a traditional app, there's no user authentication needed. The "key" is the password you set or the URL fragment - that's what grants access.
Let's create our project directory and initialize Git version control.
$ mkdir -p ~/projects/encrypted-paste # Create project folder $ cd ~/projects/encrypted-paste $ mkdir -p nginx php # Create subdirectories
$ git init $ git config user.name "Your Name" $ git config user.email "you@example.com"
$ cat > .gitignore << 'EOF' # Environment files .env # Editor files .vscode/ .idea/ *.swp # OS files .DS_Store Thumbs.db EOF
Now we'll create the pastebin application. The key is separating the encryption (JavaScript, happens in browser) from storage (PHP, happens on server).
We need a simple table to store pastes. Each paste needs:
$ cat > php/schema.sql << 'EOF' CREATE TABLE IF NOT EXISTS pastes ( id VARCHAR(16) PRIMARY KEY, encrypted_data TEXT NOT NULL, iv VARCHAR(32) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP NULL, INDEX idx_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; EOF
Understanding the Schema
VARCHAR(16) for ID - We'll generate short, readable IDs like "abc123xyz"TEXT for encrypted_data - The ciphertext can be quite longiv VARCHAR(32) - The initialization vector (salt) needed for AES decryptionexpires_at - Allows pastes to auto-delete after a certain time$ cat > php/db.php << 'EOF' host = getenv('DB_HOST') ?: 'mariadb'; $this->db = getenv('DB_NAME') ?: 'pastebin'; $this->user = getenv('DB_USER') ?: 'pasteuser'; $this->pass = getenv('DB_PASS') ?: 'pastepass'; } public function getConnection() { if ($this->pdo === null) { try { $dsn = "mysql:host={$this->host};dbname={$this->db};charset=utf8mb4"; $this->pdo = new PDO($dsn, $this->user, $this->pass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, ]); } catch (PDOException $e) { http_response_code(500); die("Database error"); } } return $this->pdo; } public function query($sql, $params = []) { $stmt = $this->getConnection()->prepare($sql); $stmt->execute($params); return $stmt; } public function fetchOne($sql, $params = []) { return $this->query($sql, $params)->fetch(); } } $db = new Database(); EOF
This PHP file handles saving and retrieving pastes. It doesn't know the encryption key - it just stores and returns encrypted data.
$ mkdir -p php/api
$ cat > php/api/save.php << 'EOF' 'Method not allowed']); exit; } $data = json_decode(file_get_contents('php://input'), true); if (!isset($data['encrypted_data']) || !isset($data['iv'])) { http_response_code(400); echo json_encode(['error' => 'Missing data']); exit; } // Generate a short, unique ID $id = substr(str_replace(['+', '/', '='], '', base64_encode(random_bytes(12))), 0, 12); // Calculate expiration (default: 24 hours) $expires_at = date('Y-m-d H:i:s', strtotime('+24 hours')); // Insert into database $db->query( "INSERT INTO pastes (id, encrypted_data, iv, expires_at) VALUES (?, ?, ?, ?)", [$id, $data['encrypted_data'], $data['iv'], $expires_at] ); // Delete expired pastes in background $db->query("DELETE FROM pastes WHERE expires_at < NOW()"); echo json_encode(['id' => $id]); EOF
$ cat > php/api/get.php << 'EOF' 16) { http_response_code(400); echo json_encode(['error' => 'Invalid ID']); exit; } $paste = $db->fetchOne( "SELECT * FROM pastes WHERE id = ?", [$id] ); if (!$paste) { http_response_code(404); echo json_encode(['error' => 'Paste not found']); exit; } // Check if expired if ($paste['expires_at'] && strtotime($paste['expires_at']) < time()) { $db->query("DELETE FROM pastes WHERE id = ?", [$id]); http_response_code(404); echo json_encode(['error' => 'Paste expired']); exit; } echo json_encode([ 'encrypted_data' => $paste['encrypted_data'], 'iv' => $paste['iv'] ]); EOF
This is the main page where users enter and view pastes. It includes all the JavaScript for client-side encryption.
$ cat > php/index.php << 'EOF' <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Encrypted Pastebin</title> <link rel="stylesheet" href="style.css"> </head> <body> <header> <h1>🔐 Encrypted Pastebin</h1> <p>Your text is encrypted in your browser. The server never sees it.</p> </header> <main> <div id="new-paste"> <h2>New Paste</h2> <textarea id="plaintext" placeholder="Enter your secret text here..." rows="10"></textarea> <button id="encrypt-btn" class="btn">Encrypt & Save</button> </div> <div id="view-paste" style="display:none;"> <h2>View Paste</h2> <textarea id="decrypted" rows="10" readonly></textarea> <p class="info">This paste was decrypted locally using the key in the URL.</p> <a href="index.php" class="btn">Create New Paste</a> </div> <div id="result" style="display:none;"> <h2>Paste Created!</h2> <p>Share this link (the key is in the URL after #):</p> <input type="text" id="share-link" readonly> <button id="copy-btn" class="btn">Copy Link</button> </div> </main> <script> // Generate a random encryption key async function generateKey() { return await crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); } // Export key to base64 for URL async function exportKey(key) { const exported = await crypto.subtle.exportKey('raw', key); return btoa(String.fromCharCode(...new Uint8Array(exported))); } // Import key from base64 async function importKey(base64Key) { const keyData = Uint8Array.from(atob(base64Key), c => c.charCodeAt(0)); return await crypto.subtle.importKey( 'raw', keyData, 'AES-GCM', true, ['encrypt', 'decrypt'] ); } // Encrypt function async function encrypt(text, key) { const encoder = new TextEncoder(); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv }, key, encoder.encode(text) ); return { encrypted_data: btoa(String.fromCharCode(...new Uint8Array(encrypted))), iv: btoa(String.fromCharCode(...iv)) }; } // Decrypt function async function decrypt(encryptedData, iv, key) { const encoder = new TextEncoder(); const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: Uint8Array.from(atob(iv), c => c.charCodeAt(0)) }, key, Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0)) ); return new TextDecoder().decode(decrypted); } // Save paste to server async function savePaste(encryptedData, iv) { const response = await fetch('api/save.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ encrypted_data: encryptedData, iv: iv }) }); return await response.json(); } // Get paste from server async function getPaste(id) { const response = await fetch('api/get.php?id=' + encodeURIComponent(id)); if (!response.ok) throw new Error('Paste not found'); return await response.json(); } // Check if we have a paste ID in URL const hash = window.location.hash.slice(1); if (hash) { document.getElementById('new-paste').style.display = 'none'; const [id, keyBase64] = hash.split('|'); getPaste(id).then(paste => { importKey(keyBase64).then(key => { decrypt(paste.encrypted_data, paste.iv, key).then(plaintext => { document.getElementById('decrypted').value = plaintext; document.getElementById('view-paste').style.display = 'block'; }); }); }).catch(err => { document.getElementById('view-paste').innerHTML = '<p class="error">Paste not found or expired.</p><a href="index.php" class="btn">Create New Paste</a>'; document.getElementById('view-paste').style.display = 'block'; }); } // Encrypt button click handler document.getElementById('encrypt-btn').addEventListener('click', async () => { const plaintext = document.getElementById('plaintext').value; if (!plaintext) return alert('Please enter some text'); const key = await generateKey(); const { encrypted_data, iv } = await encrypt(plaintext, key); const keyBase64 = await exportKey(key); const result = await savePaste(encrypted_data, iv); const shareUrl = window.location.origin + window.location.pathname + '#' + result.id + '|' + keyBase64; document.getElementById('share-link').value = shareUrl; document.getElementById('new-paste').style.display = 'none'; document.getElementById('result').style.display = 'block'; }); // Copy button document.getElementById('copy-btn').addEventListener('click', () => { const input = document.getElementById('share-link'); input.select(); document.execCommand('copy'); alert('Link copied!'); }); </script> </body> </html> EOF
How the Encryption Works
The magic: when you share the link, the recipient's browser extracts the key from the URL fragment, downloads the encrypted data from the server, and decrypts it locally. The server never sees the key or the plaintext.
$ cat > php/style.css << 'EOF' :root { --ivory: #FFFFF0; --white: #FFFFFF; --black: #0A0A0A; --charcoal: #2A2A2A; --dark-green: #006400; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--ivory); color: var(--black); line-height: 1.6; margin: 0; padding: 0; } header { background: var(--black); color: var(--white); padding: 2rem; text-align: center; } header h1 { margin: 0; font-size: 2rem; } main { max-width: 700px; margin: 0 auto; padding: 2rem; } textarea { width: 100%; padding: 1rem; border: 2px solid var(--black); font-family: monospace; font-size: 1rem; resize: vertical; box-sizing: border-box; } textarea:focus { outline: none; box-shadow: 4px 4px 0 var(--black); } .btn { background: var(--black); color: var(--white); padding: 0.75rem 1.5rem; border: 2px solid var(--black); font-size: 1rem; cursor: pointer; margin-top: 1rem; display: inline-block; } .btn:hover { background: var(--white); color: var(--black); } .info { background: var(--white); border: 2px solid var(--black); padding: 1rem; font-size: 0.9rem; } .error { color: #8B0000; } #share-link { width: 100%; padding: 0.5rem; border: 2px solid var(--black); font-family: monospace; font-size: 0.9rem; margin: 1rem 0; } footer { background: var(--black); color: var(--white); text-align: center; padding: 1.5rem; margin-top: 2rem; } EOF
$ cat > docker-compose.yml << 'EOF' version: '3.8' services: nginx: image: nginx:alpine container_name: paste-nginx ports: - "80:80" volumes: - ./nginx:/etc/nginx/conf.d - ./php:/var/www/html depends_on: - php-fpm restart: unless-stopped php-fpm: image: php:8.2-fpm-alpine container_name: paste-php volumes: - ./php:/var/www/html depends_on: - mariadb environment: - DB_HOST=mariadb - DB_NAME=pastebin - DB_USER=pasteuser - DB_PASS=pastepass restart: unless-stopped mariadb: image: mariadb:10.11 container_name: paste-mariadb environment: - MYSQL_ROOT_PASSWORD=rootpass - MYSQL_DATABASE=pastebin - MYSQL_USER=pasteuser - MYSQL_PASSWORD=pastepass volumes: - mariadb-data:/var/lib/mysql restart: unless-stopped volumes: mariadb-data: EOF
$ cat > nginx/default.conf << 'EOF' server { listen 80; server_name localhost; root /var/www/html; index index.php index.html; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass php-fpm:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } location ~ /\. { deny all; } } EOF
$ docker-compose up -d Creating network "encrypted-paste_default" with the default driver Creating paste-mariadb ... done Creating paste-php ... done Creating paste-nginx ... done
$ docker-compose exec mariadb mysql -upasteuser -ppastepass pastebin < php/schema.sql # Initialize database
Visit http://localhost to test! Enter some text, click Encrypt & Save, and you'll get a shareable link. The magic part is the key after the # in the URL - that's what decrypts the paste.
$ docker-compose down
$ ssh your-username@your-vps-ip
your-username@vps:~$ sudo apt update && sudo apt install -y apt-transport-https ca-certificates curl gnupg lsb-release
your-username@vps:~$ curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
your-username@vps:~$ echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
your-username@vps:~$ sudo apt update && sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
your-username@vps:~$ sudo usermod -aG docker $USER && newgrp docker
your-username@vps:~$ sudo ufw allow 22/tcp && sudo ufw allow 80/tcp && sudo ufw allow 443/tcp && sudo ufw enable
On your local machine, commit and push to GitHub:
$ cd ~/projects/encrypted-paste $ git add . && git commit -m "Initial encrypted pastebin" $ git remote add origin git@github.com:yourusername/encrypted-paste.git $ git push -u origin main
On your VPS, clone the repo:
your-username@vps:~$ cd ~ && git clone git@github.com:yourusername/encrypted-paste.git pastebin
your-username@vps:~$ cd ~/pastebin && docker-compose up -d
your-username@vps:~$ docker-compose exec mariadb mysql -upasteuser -ppastepass pastebin < php/schema.sql
your-username@vps:~$ sudo apt install -y certbot python3-certbot-nginx
your-username@vps:~$ sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Follow the prompts. Your encrypted pastebin is now live with HTTPS!
Add a cron job to automatically delete expired pastes:
your-username@vps:~$ crontab -e
Add this line to run cleanup every hour:
0 * * * * docker exec paste-mariadb mysql -upasteuser -ppastepass pastebin -e "DELETE FROM pastes WHERE expires_at < NOW()"
your-username@vps:~$ docker exec paste-mariadb mysqldump -upasteuser -ppastepass pastebin > ~/backups/pastebin-$(date +%Y%m%d).sql
your-username@vps:~$ cd ~/pastebin && git pull origin main && docker-compose down && docker-compose up -d
You've built a privacy-respecting pastebin with real-world encryption!
Next Steps:
This tool is genuinely useful and respects user privacy. Unlike commercial pastebins, you know exactly what happens to the data - because you run it.
The revolution will not be proprietary.