OVERVIEW SETUP CODE DEPLOY

BUILD AN ENCRYPTED PASTEBIN

// 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.

// What You'll Learn

This project combines everything you've learned and adds some new concepts:

How It All Works

Here's the magic of client-side encryption:

  1. You enter text - You type or paste your secret into the browser
  2. Browser generates a key - JavaScript creates a random encryption key
  3. Encryption happens locally - Your browser encrypts the text using AES-256-GCM
  4. Only encrypted data is sent - The server receives gibberish it cannot read
  5. Server stores the ciphertext - The encrypted data is saved to the database
  6. Recipient decrypts - With the key (in the URL fragment), they can decrypt and read

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:

The Architecture

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.

// Part 1: Set Up Local Development

Let's create our project directory and initialize Git version control.

1.1 Create Project Directory

$ mkdir -p ~/projects/encrypted-paste
# Create project folder
$ cd ~/projects/encrypted-paste
$ mkdir -p nginx php
# Create subdirectories

1.2 Initialize Git

$ git init
$ git config user.name "Your Name"
$ git config user.email "you@example.com"

1.3 Create .gitignore

$ cat > .gitignore << 'EOF'
# Environment files
.env

# Editor files
.vscode/
.idea/
*.swp

# OS files
.DS_Store
Thumbs.db
EOF

// Part 2: Create the Pastebin Code

Now we'll create the pastebin application. The key is separating the encryption (JavaScript, happens in browser) from storage (PHP, happens on server).

2.1 The Database Schema

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

2.2 Database Connection (db.php)

$ 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

2.3 The API Endpoints (api.php)

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

2.4 The Frontend (index.php)

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.

2.5 Basic CSS Styling

$ 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

// Part 3: Dockerize Your Pastebin

3.1 Create docker-compose.yml

$ 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

3.2 Configure nginx

$ 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

3.3 Run Locally

$ 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.

3.4 Stop Containers

$ docker-compose down

// Part 4: Deploy to Your VPS

4.1 Connect to Your VPS

$ ssh your-username@your-vps-ip

4.2 Install Docker

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

4.3 Add User to Docker Group

your-username@vps:~$ sudo usermod -aG docker $USER && newgrp docker

4.4 Configure Firewall

your-username@vps:~$ sudo ufw allow 22/tcp && sudo ufw allow 80/tcp && sudo ufw allow 443/tcp && sudo ufw enable

4.5 Transfer Code

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

4.6 Run on VPS

your-username@vps:~$ cd ~/pastebin && docker-compose up -d
your-username@vps:~$ docker-compose exec mariadb mysql -upasteuser -ppastepass pastebin < php/schema.sql

4.7 Add SSL with Let's Encrypt

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!

// Part 5: Upkeep and Maintenance

5.1 Clean Up Expired Pastes

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()"

5.2 Backup

your-username@vps:~$ docker exec paste-mariadb mysqldump -upasteuser -ppastepass pastebin > ~/backups/pastebin-$(date +%Y%m%d).sql

5.3 Update

your-username@vps:~$ cd ~/pastebin && git pull origin main && docker-compose down && docker-compose up -d

// Summary

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.