Build a pastebin with expiry

A share-your-text service in three files — Express stores each paste in SQLite with a chosen lifespan, hands back a link, and sweeps expired pastes automatically. Deploy it, test it with curl, extend it.

Skip the setup This guide has a one-click starter template that installs everything below onto your server.

A pastebin takes a chunk of text, saves it, and gives you a short link to share — perfect for logs, config snippets, or error messages too long for chat. This one adds the feature that makes a pastebin actually usable: expiry, so pastes clean themselves up instead of piling up forever. It's three files on the Node.js application, and it reuses the same Express + SQLite shape as the URL shortener.

At a glance
You're building A text-sharing service with per-paste expiry
You need A Falix server running the Node.js application
Plan Any — free runs while your session timer has time, premium runs 24/7
Time About thirty minutes

If Node is new to you, read Node.js on Falix first.

What it does

Feature How
Create a paste POST /api/paste with { content, expiry } returns a short id
View raw GET /:id returns the paste as plain text
Expiry choices 10 minutes, 1 hour, 1 day, 1 week, or never
Auto-cleanup A timer deletes expired pastes every minute
Web form public/index.html to write and save without curl

The files

package.json:

{
  "name": "pastebin",
  "version": "1.0.0",
  "private": true,
  "main": "index.js",
  "dependencies": {
    "better-sqlite3": "^11.8.1",
    "express": "^4.21.2"
  }
}

index.js:

const express = require('express');
const Database = require('better-sqlite3');
const crypto = require('node:crypto');
const path = require('node:path');

const db = new Database('pastes.db');
db.exec(`CREATE TABLE IF NOT EXISTS pastes (
  id         TEXT PRIMARY KEY,
  content    TEXT NOT NULL,
  created_at INTEGER NOT NULL,
  expires_at INTEGER
)`);

// How long each choice lasts, in seconds. null = never expires.
const TTL = { '10m': 600, '1h': 3600, '1d': 86400, '1w': 604800, never: null };

// Sweep expired pastes once a minute so the file doesn't grow forever.
setInterval(() => {
  db.prepare('DELETE FROM pastes WHERE expires_at IS NOT NULL AND expires_at < ?')
    .run(Date.now());
}, 60_000).unref();

const app = express();
app.use(express.json({ limit: '1mb' }));
app.use(express.static(path.join(__dirname, 'public')));

app.post('/api/paste', (req, res) => {
  const { content, expiry } = req.body;
  if (!content || typeof content !== 'string') {
    return res.status(400).json({ error: 'content is required' });
  }
  const seconds = expiry in TTL ? TTL[expiry] : TTL['1d'];
  const id = crypto.randomBytes(5).toString('base64url').slice(0, 8);
  const expiresAt = seconds ? Date.now() + seconds * 1000 : null;
  db.prepare('INSERT INTO pastes (id, content, created_at, expires_at) VALUES (?, ?, ?, ?)')
    .run(id, content, Date.now(), expiresAt);
  res.json({ id, url: `/${id}` });
});

// View a paste as raw text.
app.get('/:id', (req, res) => {
  const row = db.prepare('SELECT content, expires_at FROM pastes WHERE id = ?')
    .get(req.params.id);
  if (!row || (row.expires_at && row.expires_at < Date.now())) {
    return res.status(404).type('text/plain').send('Paste not found or expired');
  }
  res.type('text/plain').send(row.content);
});

const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));

public/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Pastebin</title>
</head>
<body>
  <h1>New paste</h1>
  <textarea id="content" rows="12" cols="60" placeholder="Paste your text here..."></textarea>
  <div>
    <label>Expires:
      <select id="expiry">
        <option value="10m">10 minutes</option>
        <option value="1h">1 hour</option>
        <option value="1d" selected>1 day</option>
        <option value="1w">1 week</option>
        <option value="never">Never</option>
      </select>
    </label>
    <button id="save">Create paste</button>
  </div>
  <div id="out"></div>
  <script>
    document.getElementById('save').addEventListener('click', async () => {
      const content = document.getElementById('content').value;
      const expiry = document.getElementById('expiry').value;
      const res = await fetch('/api/paste', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content, expiry }),
      });
      const data = await res.json();
      const out = document.getElementById('out');
      if (data.url) {
        const full = location.origin + data.url;
        out.innerHTML = 'Saved: <a href="' + full + '">' + full + '</a>';
      } else {
        out.textContent = data.error || 'Something went wrong';
      }
    });
  </script>
</body>
</html>

How it works

  • Two timestamps decide a paste's life. created_at records when it was made; expires_at is either a future millisecond time or null for "never". The TTL table maps each dropdown choice to a number of seconds — one place to change if you want different options.
  • Expiry is enforced twice, on purpose. The GET /:id handler treats an expired paste as a 404 immediately, so a paste is unreachable the second it lapses even if it's still on disk. The one-minute setInterval sweep is the janitor that actually frees the space. Belt and braces.
  • .unref() keeps shutdown clean. It tells Node the cleanup timer shouldn't, by itself, keep the process alive — a small tidy-up detail.
  • Raw text on purpose. res.type('text/plain') serves the paste as-is so code and logs show exactly as pasted, with nothing rendered or run.

🎯 Good to know: The express.json({ limit: '1mb' }) cap is your friend — without a limit, one giant paste could exhaust memory. On the free plan's 2.5 GB shared RAM, keep an eye on limits like this; see Out of memory.

Run it on Falix

  1. Upload the three files (make the public folder for index.html), or deploy from Git.
  2. Press Start. The console runs npm install, then prints Listening on port … — your success signal and the line that flips the server online.
  3. Open your address from the Network page, write something, pick an expiry, and save. Open the returned link to read it back.

The listen call already reads SERVER_PORT and binds 0.0.0.0, so there's nothing to configure for the port.

Command-line round-trip:

curl -X POST http://YOUR_ADDRESS:PORT/api/paste \
  -H "Content-Type: application/json" \
  -d '{"content": "hello\nworld", "expiry": "10m"}'
# -> {"id":"Gkfo1a2b","url":"/Gkfo1a2b"}

curl http://YOUR_ADDRESS:PORT/Gkfo1a2b
# -> hello
#    world

🎯 Good to know: Pastes live in pastes.db on the server. Restarts keep them; a reinstall or application switch wipes the file. For storage that survives anything, move to a managed database.

Make it yours

  • Syntax highlighting. Serve a view page that fetches the raw text and runs it through a client-side highlighter, keeping the raw route for curl.
  • Burn after reading. Add a views limit and delete the paste after the first read — handy for secrets.
  • A random title or line count shown on save, using the columns you already store.
  • Rate limiting so nobody floods your database — pair with Protecting your API.

Beyond the Falix layer this is plain Express; expressjs.com covers middleware and routing in full.

Troubleshooting

  • content is required (400) — the request body was empty or not JSON. Send Content-Type: application/json and a non-empty content field.
  • Paste 404s right after creating it — you chose a very short expiry, or the server restarted onto a fresh pastes.db. Check the expiry you picked.
  • Cannot find module on start — the install failed; read the npm output at the top of the console, or install from the Packages page and restart.
  • Page won't load at all — port/bind problem, not code: I can't reach my app.

Next steps

Was this guide helpful?