// 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.
This project builds on what you learned in the pastebin tutorial and adds file handling:
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:
$ mkdir -p ~/projects/private-fileshare $ cd ~/projects/private-fileshare $ mkdir -p nginx php/uploads php/api # uploads folder will store encrypted files
$ git init $ git config user.name "Your Name" $ git config user.email "you@example.com"
$ cat > .gitignore << 'EOF' # Uploaded files (these can be large and are regenerated) php/uploads/* # Environment .env # Editor .vscode/ .idea/ *.swp # OS .DS_Store EOF
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
$ 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
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
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
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
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
$ 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
$ 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
$ 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.
$ 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!
$ 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:
$ 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
your-username@vps:~$ cd ~/fileshare && docker-compose up -d
your-username@vps:~$ docker-compose exec mariadb mysql -ufileuser -pfilepass fileshare < 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
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.