OVERVIEW SETUP CODE DEPLOY

BUILD A PRIVATE FILE SHARE

// Send files that vanish after download

// WHAT WE'RE BUILDING

In this tutorial, you'll create your own encrypted file sharing tool - similar to Firefox Send or WeTransfer, but self-hosted and privacy-focused. Files are encrypted in your browser, uploaded to your server, and automatically deleted after download (or after a time limit). The server never sees your files unencrypted.

// WHY THIS MATTERS

When you use services like WeTransfer or Dropbox, your files pass through their servers. They can see what you're sharing, and they keep copies. With client-side encryption, only the recipient can decrypt what you send. And with auto-delete, there's no trace left behind. Perfect for sharing sensitive documents, passwords, or anything you don't want lingering on a server.

// What You'll Learn

This project builds on what you learned in the pastebin tutorial and adds file handling:

How It All Works

  1. You select a file - Choose a file from your computer
  2. Browser encrypts it - JavaScript encrypts the file with AES-256-GCM before upload
  3. Upload to server - Encrypted file is sent to your VPS
  4. Server stores file + metadata - Saves encrypted file and creates a unique download ID
  5. Share link with key - Recipient gets a link containing the decryption key
  6. One download then delete - File is served once, then permanently deleted

The magic is that the encryption key is in the URL fragment (after #), so it's never sent to the server. The server stores only encrypted blobs.

Prerequisites

Complete these guides first:

// Part 1: Set Up Local Development

1.1 Create Project Directory

$ mkdir -p ~/projects/private-fileshare
$ cd ~/projects/private-fileshare
$ mkdir -p nginx php/uploads php/api
# uploads folder will store encrypted files

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'
# Uploaded files (these can be large and are regenerated)
php/uploads/*

# Environment
.env

# Editor
.vscode/
.idea/
*.swp

# OS
.DS_Store
EOF

// Part 2: Create the File Share Code

2.1 Database Schema

We need to track file metadata: original name, unique ID, encryption info, download count.

$ cat > php/schema.sql << 'EOF'
CREATE TABLE IF NOT EXISTS files (
    id VARCHAR(16) PRIMARY KEY,
    original_name VARCHAR(255) NOT NULL,
    iv VARCHAR(32) NOT NULL,
    downloads_remaining INT DEFAULT 1,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP NULL,
    INDEX idx_expires (expires_at),
    INDEX idx_downloads (downloads_remaining)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
EOF

2.2 Database Connection

$ cat > php/db.php << 'EOF'
host = getenv('DB_HOST') ?: 'mariadb';
        $this->db = getenv('DB_NAME') ?: 'fileshare';
        $this->user = getenv('DB_USER') ?: 'fileuser';
        $this->pass = getenv('DB_PASS') ?: 'filepass';
    }

    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 Upload API (upload.php)

Handles encrypted file uploads. Receives encrypted data and stores it.

$ cat > php/api/upload.php << 'EOF'
 'Method not allowed']);
    exit;
}

$data = json_decode(file_get_contents('php://input'), true);

if (!isset($data['encrypted_data']) || !isset($data['iv']) || !isset($data['filename'])) {
    http_response_code(400);
    echo json_encode(['error' => 'Missing data']);
    exit;
}

// Generate unique ID
$id = substr(str_replace(['+', '/', '='], '', base64_encode(random_bytes(12))), 0, 12);

// Save encrypted file to disk
$uploadDir = __DIR__ . '/../uploads/';
$filename = $id . '.enc';
file_put_contents($uploadDir . $filename, base64_decode($data['encrypted_data']));

// Calculate expiration (default: 24 hours)
$expires_at = date('Y-m-d H:i:s', strtotime('+24 hours'));

// Insert metadata to database
$db->query(
    "INSERT INTO files (id, original_name, iv, downloads_remaining, expires_at) VALUES (?, ?, ?, 1, ?)",
    [$id, $data['filename'], $data['iv'], $expires_at]
);

// Clean up expired files
$db->query("DELETE FROM files WHERE expires_at < NOW()");

echo json_encode(['id' => $id]);
EOF

2.4 Download API (download.php)

Serves encrypted file once, then deletes it.

$ cat > php/api/download.php << 'EOF'
 16) {
    http_response_code(400);
    echo 'Invalid ID';
    exit;
}

$file = $db->fetchOne(
    "SELECT * FROM files WHERE id = ?",
    [$id]
);

if (!$file) {
    http_response_code(404);
    echo 'File not found';
    exit;
}

// Check if expired
if ($file['expires_at'] && strtotime($file['expires_at']) < time()) {
    @unlink(__DIR__ . '/../uploads/' . $id . '.enc');
    $db->query("DELETE FROM files WHERE id = ?", [$id]);
    http_response_code(404);
    echo 'File expired';
    exit;
}

// Check downloads remaining
if ($file['downloads_remaining'] <= 0) {
    http_response_code(410);
    echo 'Download limit reached';
    exit;
}

// Read and output encrypted file
$filepath = __DIR__ . '/../uploads/' . $id . '.enc';
if (!file_exists($filepath)) {
    http_response_code(404);
    echo 'File not found on disk';
    exit;
}

$encryptedData = file_get_contents($filepath);

// Set headers
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $file['original_name'] . '.enc"');
header('Content-Length: ' . strlen($encryptedData));
echo $encryptedData;

// Delete after one download
@unlink($filepath);
$db->query("DELETE FROM files WHERE id = ?", [$id]);
EOF

2.5 Get File Info API (info.php)

Returns file metadata (not the file itself) for the decryption process.

$ cat > php/api/info.php << 'EOF'
 'Invalid ID']);
    exit;
}

$file = $db->fetchOne(
    "SELECT original_name, iv FROM files WHERE id = ?",
    [$id]
);

if (!$file) {
    http_response_code(404);
    echo json_encode(['error' => 'File not found']);
    exit;
}

echo json_encode([
    'filename' => $file['original_name'],
    'iv' => $file['iv']
]);
EOF

2.6 Frontend (index.php)

Upload and download interface with 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>Private File Share</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1>📁 Private File Share</h1>
        <p>Files are encrypted in your browser. Auto-delete after download.</p>
    </header>

    <main>
        <div id="upload-section">
            <h2>Upload a File</h2>
            <input type="file" id="file-input">
            <button id="upload-btn" class="btn">Encrypt & Upload</button>
            <p id="upload-status"></p>
        </div>

        <div id="result-section" style="display:none;">
            <h2>File Ready!</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>
            <p class="warning">⚠️ This file will be deleted after one download!</p>
        </div>

        <div id="download-section" style="display:none;">
            <h2>Download File</h2>
            <p id="filename-display"></p>
            <button id="download-btn" class="btn">Decrypt & Download</button>
            <p id="download-status"></p>
        </div>
    </main>

    <script>
        // Generate encryption key
        async function generateKey() {
            return await crypto.subtle.generateKey(
                { name: 'AES-GCM', length: 256 },
                true,
                ['encrypt', 'decrypt']
            );
        }

        // Export key to base64
        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, ['decrypt']
            );
        }

        // Encrypt file
        async function encryptFile(file, key) {
            const iv = crypto.getRandomValues(new Uint8Array(12));
            
            const encrypted = await crypto.subtle.encrypt(
                { name: 'AES-GCM', iv: iv },
                key,
                await file.arrayBuffer()
            );

            return {
                encrypted_data: btoa(String.fromCharCode(...new Uint8Array(encrypted))),
                iv: btoa(String.fromCharCode(...iv))
            };
        }

        // Decrypt file
        async function decryptFile(encryptedData, iv, key) {
            return 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))
            );
        }

        // Upload handler
        document.getElementById('upload-btn').addEventListener('click', async () => {
            const fileInput = document.getElementById('file-input');
            const file = fileInput.files[0];
            if (!filePlease select a file) return alert('');

            document.getElementById('upload-status').textContent = 'Encrypting...';

            const key = await generateKey();
            const { encrypted_data, iv } = await encryptFile(file, key);
            const keyBase64 = await exportKey(key);

            document.getElementById('upload-status').textContent = 'Uploading...';

            const response = await fetch('api/upload.php', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ 
                    encrypted_data: encrypted_data, 
                    iv: iv,
                    filename: file.name
                })
            });

            const result = await response.json();
            
            const shareUrl = window.location.origin + window.location.pathname + '#' + result.id + '|' + keyBase64;
            document.getElementById('share-link').value = shareUrl;
            
            document.getElementById('upload-section').style.display = 'none';
            document.getElementById('result-section').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!');
        });

        // Check for download in URL
        const hash = window.location.hash.slice(1);
        if (hash) {
            const [id, keyBase64] = hash.split('|');
            
            document.getElementById('upload-section').style.display = 'none';
            document.getElementById('download-section').style.display = 'block';

            // Get file info
            fetch('api/info.php?id=' + encodeURIComponent(id))
                .then(r => r.json())
                .then(info => {
                    document.getElementById('filename-display').textContent = 
                        'File: ' + info.filename;
                });

            // Download button
            document.getElementById('download-btn').addEventListener('click', async () => {
                document.getElementById('download-status').textContent = 'Downloading & decrypting...';

                // Download encrypted file
                const response = await fetch('api/download.php?id=' + encodeURIComponent(id));
                if (!response.ok) {
                    document.getElementById('download-status').textContent = 'Error: ' + response.statusText;
                    return;
                }

                const encryptedBlob = await response.blob();
                const encryptedArray = await encryptedBlob.arrayBuffer();
                const encryptedBase64 = btoa(String.fromCharCode(...new Uint8Array(encryptedArray)));

                // Get IV from info endpoint
                const info = await fetch('api/info.php?id=' + encodeURIComponent(id)).then(r => r.json());

                // Decrypt
                const key = await importKey(keyBase64);
                const decrypted = await decryptFile(encryptedBase64, info.iv, key);

                // Create download
                const blob = new Blob([decrypted]);
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = info.filename;
                a.click();
                URL.revokeObjectURL(url);

                document.getElementById('download-status').textContent = 'Download complete! File has been deleted.';
            });
        }
    </script>
</body>
</html>
EOF

2.7 CSS Styling

$ cat > php/style.css << 'EOF'
:root {
    --ivory: #FFFFF0;
    --white: #FFFFFF;
    --black: #0A0A0A;
    --charcoal: #2A2A2A;
    --dark-red: #8B0000;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background: var(--ivory);
    color: var(--black);
    line-height: 1.6;
    margin: 0;
}

header {
    background: var(--black);
    color: var(--white);
    padding: 2rem;
    text-align: center;
}

header h1 { margin: 0; }

main {
    max-width: 700px;
    margin: 0 auto;
    padding: 2rem;
}

input[type="file"] {
    display: block;
    margin: 1rem 0;
    padding: 1rem;
    border: 2px dashed var(--black);
    width: 100%;
    box-sizing: border-box;
}

.btn {
    background: var(--black);
    color: var(--white);
    padding: 0.75rem 1.5rem;
    border: 2px solid var(--black);
    font-size: 1rem;
    cursor: pointer;
}

.btn:hover {
    background: var(--white);
    color: var(--black);
}

#share-link {
    width: 100%;
    padding: 0.5rem;
    border: 2px solid var(--black);
    font-family: monospace;
    margin: 1rem 0;
}

.warning {
    color: var(--dark-red);
    font-weight: bold;
    margin-top: 1rem;
}

footer {
    background: var(--black);
    color: var(--white);
    text-align: center;
    padding: 1.5rem;
    margin-top: 2rem;
}
EOF

// Part 3: Dockerize Your File Share

3.1 Create docker-compose.yml

$ cat > docker-compose.yml << 'EOF'
version: '3.8'

services:
  nginx:
    image: nginx:alpine
    container_name: fileshare-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: fileshare-php
    volumes:
      - ./php:/var/www/html
    depends_on:
      - mariadb
    environment:
      - DB_HOST=mariadb
      - DB_NAME=fileshare
      - DB_USER=fileuser
      - DB_PASS=filepass
    restart: unless-stopped

  mariadb:
    image: mariadb:10.11
    container_name: fileshare-mariadb
    environment:
      - MYSQL_ROOT_PASSWORD=rootpass
      - MYSQL_DATABASE=fileshare
      - MYSQL_USER=fileuser
      - MYSQL_PASSWORD=filepass
    volumes:
      - mariadb-data:/var/lib/mysql
    restart: unless-stopped

volumes:
  mariadb-data:
EOF

3.2 nginx Configuration

$ cat > nginx/default.conf << 'EOF'
server {
    listen 80;
    server_name localhost;
    root /var/www/html;
    index index.php index.html;

    client_max_body_size 100M;

    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

Important: client_max_body_size

This nginx setting allows large file uploads. Set to 100M (100 megabytes). If you need larger files, increase this value.

3.3 Run Locally

$ docker-compose up -d
$ docker-compose exec mariadb mysql -ufileuser -pfilepass fileshare < php/schema.sql

Visit http://localhost. Upload a file, get the link, and try downloading it. The file will vanish after one download!

3.4 Stop

$ docker-compose down

// Part 4: Deploy to Your VPS

4.1 Connect

$ 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

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

4.4 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:

$ cd ~/projects/private-fileshare
$ git add . && git commit -m "Initial file share project"
$ git remote add origin git@github.com:yourusername/private-fileshare.git
$ git push -u origin main

On your VPS:

your-username@vps:~$ cd ~ && git clone git@github.com:yourusername/private-fileshare.git fileshare

4.6 Run

your-username@vps:~$ cd ~/fileshare && docker-compose up -d
your-username@vps:~$ docker-compose exec mariadb mysql -ufileuser -pfilepass fileshare < php/schema.sql

4.7 SSL

your-username@vps:~$ sudo apt install -y certbot python3-certbot-nginx
your-username@vps:~$ sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

// Summary

You've built a private, encrypted file sharing tool!

Next Steps:

This is a genuinely useful tool. No accounts, no tracking, no retention. Just secure file transfer.

The revolution will not be proprietary.