A saved link is useful only if you can find it again. Browser bookmarks handle basic storage, but notes, consistent tags, and searchable context make a personal library much more useful.

In this tutorial, you’ll build a password-protected bookmark manager that runs on your computer. It stores links in SQLite, works without JavaScript, and runs in Docker with persistent storage.

What you’ll build

Your application will support:

· Adding, editing, and deleting bookmarks. · Notes, normalized tags, and favorites. · Searching across titles, URLs, notes, and tags. · Filtering by an exact tag or favorites. · Pagination with 50 bookmarks per page. · JSON export and duplicate-safe import. · Full SQLite backup and restore. · Password login and CSRF protection.

The application uses system fonts and local assets. Saving a bookmark does not fetch its URL, contact a metadata service, or send your library elsewhere.

Prerequisites

You’ll need:

· Python 3.12 or newer. · Basic Python, HTML, and HTTP knowledge. · A Bash-compatible terminal on Linux, macOS, or WSL. · Docker with Docker Compose for the container setup.

SQLite comes with Python. You do not need a separate database server.

Project structure

personal-bookmarks/
├── app.py
├── requirements.txt
├── .env.example
├── .env
├── .gitignore
├── .dockerignore
├── Dockerfile
├── compose.yaml
├── templates/
│   ├── base.html
│   ├── login.html
│   └── index.html
├── static/
│   └── style.css
└── data/
    └── bookmarks.sqlite3

The application creates data/ automatically for native development. Docker stores its database in a named volume.

Create the project and configure secrets

mkdir personal-bookmarks
cd personal-bookmarks
mkdir templates static
python3 -m venv .venv
source .venv/bin/activate

Create requirements.txt:

Flask==3.1.3
python-dotenv==1.2.4
gunicorn==26.2.0

Install the dependencies:

pip install -r requirements.txt

Create .env.example:

SECRET_KEY=
APP_PASSWORD=
DATABASE_PATH=./data/bookmarks.sqlite3
COOKIE_SECURE=false
TRUSTED_HOSTS=localhost,127.0.0.1,[::1]

The variables control:

Variable Purpose SECRET_KEY Signs session cookies; use a random value APP_PASSWORD Password for the single-user login DATABASE_PATH SQLite database location COOKIE_SECURE Restricts session cookies to HTTPS when true TRUSTED_HOSTS Comma-separated hostnames the application accepts

Copy the example and generate your configuration:

cp .env.example .env
python - <<'PY'
import secrets
from getpass import getpass
from dotenv import set_key

password = getpass("Choose an app password, at least 12 characters: ")
if len(password) < 12:
    raise SystemExit("Password must contain at least 12 characters.")

set_key(".env", "SECRET_KEY", secrets.token_hex(32))
set_key(".env", "APP_PASSWORD", password)
PY
chmod 600 .env

Use a unique password. The application hashes it in memory at startup, while .env remains a secret configuration file on disk.

Create .gitignore:

.env
.venv/
__pycache__/
data/
*.sqlite3

Build the Flask application

The database stores one row per bookmark. A unique URL constraint prevents duplicates, while parameterized queries keep user input separate from SQL.

Tags are trimmed, converted to lowercase, and deduplicated. Internally, they use a comma-separated field; JSON exports represent them as an array.

Create app.py:

import json
import os
import secrets
import sqlite3
from contextlib import closing
from datetime import timedelta
from pathlib import Path
from urllib.parse import urlsplit

from dotenv import load_dotenv
from flask import (
    Flask, abort, flash, g, redirect, render_template,
    request, session, url_for,
)
from werkzeug.security import check_password_hash, generate_password_hash

load_dotenv()

secret = os.getenv("SECRET_KEY", "")
password = os.getenv("APP_PASSWORD", "")
if len(secret) < 32 or len(password) < 12:
    raise RuntimeError("Set SECRET_KEY (32+ characters) and APP_PASSWORD (12+).")

password_hash = generate_password_hash(password)

app = Flask(__name__)
app.config.update(
    SECRET_KEY=secret,
    MAX_CONTENT_LENGTH=2 * 1024 * 1024,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
    SESSION_COOKIE_SECURE=os.getenv("COOKIE_SECURE", "false").lower() == "true",
    PERMANENT_SESSION_LIFETIME=timedelta(hours=8),
    TRUSTED_HOSTS=os.getenv(
        "TRUSTED_HOSTS", "localhost,127.0.0.1,[::1]"
    ).split(","),
)

db_path = Path(
    os.getenv("DATABASE_PATH", "./data/bookmarks.sqlite3")
).resolve()
db_path.parent.mkdir(parents=True, exist_ok=True)

with closing(sqlite3.connect(db_path, timeout=5)) as db:
    db.execute("PRAGMA journal_mode=WAL")
    db.executescript("""
        CREATE TABLE IF NOT EXISTS bookmarks (
            id INTEGER PRIMARY KEY,
            title TEXT NOT NULL,
            url TEXT NOT NULL UNIQUE,
            notes TEXT NOT NULL DEFAULT '',
            tags TEXT NOT NULL DEFAULT '',
            favorite INTEGER NOT NULL DEFAULT 0
                CHECK (favorite IN (0, 1))
        );
    """)


def get_db():
    if "db" not in g:
        g.db = sqlite3.connect(db_path, timeout=5)
        g.db.row_factory = sqlite3.Row
    return g.db


@app.teardown_appcontext
def close_db(_error):
    db = g.pop("db", None)
    if db is not None:
        db.close()


def csrf_token():
    if "csrf" not in session:
        session["csrf"] = secrets.token_urlsafe(32)
    return session["csrf"]


app.jinja_env.globals["csrf_token"] = csrf_token


@app.before_request
def protect_requests():
    if request.endpoint is None or request.endpoint in {"static", "health"}:
        return

    if request.method == "POST":
        expected = session.get("csrf", "")
        supplied = request.form.get("csrf", "")
        if not expected or not secrets.compare_digest(
            expected.encode(), supplied.encode()
        ):
            abort(403, "Invalid CSRF token. Reload the page and try again.")

    if request.endpoint != "login" and not session.get("authenticated"):
        return redirect(url_for("login"))


@app.after_request
def security_headers(response):
    response.headers["Content-Security-Policy"] = (
        "default-src 'self'; base-uri 'none'; "
        "frame-ancestors 'none'; form-action 'self'"
    )
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["Referrer-Policy"] = "no-referrer"
    if request.endpoint != "static":
        response.headers["Cache-Control"] = "no-store"
    return response


def validate_bookmark(data):
    if not isinstance(data, dict):
        raise ValueError("Each bookmark must be an object.")

    values = {}
    for key, limit in (("title", 200), ("url", 2048), ("notes", 4000)):
        value = data.get(key, "")
        if not isinstance(value, str) or len(value.strip()) > limit:
            raise ValueError(
                f"{key} must be text with at most {limit} characters."
            )
        value.encode("utf-8")
        values[key] = value.strip()

    if not values["title"]:
        raise ValueError("Title is required.")

    url = values["url"]
    if any(ord(char) <= 32 or ord(char) == 127 for char in url) or "\\" in url:
        raise ValueError(
            "URL cannot contain spaces, control characters, or backslashes."
        )

    try:
        parts = urlsplit(url)
        valid_url = (
            parts.scheme in {"http", "https"}
            and parts.hostname
            and not parts.username
            and not parts.password
        )
        parts.port  # Reject malformed port numbers, too.
    except ValueError:
        valid_url = False

    if not valid_url:
        raise ValueError(
            "Use a complete http:// or https:// URL without credentials."
        )

    tags = data.get("tags", [])
    if not isinstance(tags, list) or len(tags) > 10:
        raise ValueError("Provide at most 10 tags.")

    if any(
        not isinstance(tag, str)
        or len(tag.strip()) > 32
        or "," in tag
        for tag in tags
    ):
        raise ValueError(
            "Tags must be text, at most 32 characters, without commas."
        )

    for tag in tags:
        tag.encode("utf-8")

    values["tags"] = ",".join(
        sorted({tag.strip().lower() for tag in tags if tag.strip()})
    )

    favorite = data.get("favorite", False)
    if type(favorite) is not bool:
        raise ValueError("favorite must be a JSON boolean.")
    values["favorite"] = int(favorite)

    return values


@app.get("/healthz")
def health():
    get_db().execute("SELECT COUNT(*) FROM bookmarks").fetchone()
    return {"status": "ok"}


@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        if check_password_hash(
            password_hash, request.form.get("password", "")
        ):
            session.clear()
            session["authenticated"] = True
            session.permanent = True
            return redirect(url_for("index"))

        return render_template(
            "login.html", error="Incorrect password."
        ), 401

    return render_template("login.html")


@app.post("/logout")
def logout():
    session.clear()
    return redirect(url_for("login"))


def find_bookmark(bookmark_id):
    if not 1 <= bookmark_id <= 2**63 - 1:
        abort(404)

    row = get_db().execute(
        "SELECT * FROM bookmarks WHERE id = ?", (bookmark_id,)
    ).fetchone()

    if row is None:
        abort(404)
    return row


@app.get("/")
def index():
    q = request.args.get("q", "").strip()[:200]
    tag = request.args.get("tag", "").strip().lower()[:32]
    favorites = request.args.get("favorites") == "1"
    page = min(
        1_000_000, max(1, request.args.get("page", 1, type=int))
    )

    rows = get_db().execute("""
        SELECT * FROM bookmarks
        WHERE instr(
            lower(title || ' ' || url || ' ' || notes || ' ' || tags),
            lower(?)
        ) > 0
          AND (? = '' OR instr(',' || tags || ',', ',' || ? || ',') > 0)
          AND (? = 0 OR favorite = 1)
        ORDER BY id DESC
        LIMIT 51 OFFSET ?
    """, (
        q, tag, tag, int(favorites), (page - 1) * 50
    )).fetchall()

    edit_id = request.args.get("edit", type=int)
    editing = find_bookmark(edit_id) if edit_id is not None else None

    return render_template(
        "index.html",
        bookmarks=rows[:50],
        has_next=len(rows) > 50,
        q=q,
        tag=tag,
        favorites=favorites,
        page=page,
        editing=editing,
    )


@app.post("/bookmarks")
@app.post("/bookmarks/<int:bookmark_id>")
def save_bookmark(bookmark_id=None):
    if bookmark_id is not None:
        find_bookmark(bookmark_id)

    try:
        values = validate_bookmark({
            **request.form,
            "tags": request.form.get("tags", "").split(","),
            "favorite": "favorite" in request.form,
        })

        with get_db() as db:
            if bookmark_id is None:
                db.execute("""
                    INSERT INTO bookmarks
                        (title, url, notes, tags, favorite)
                    VALUES (:title, :url, :notes, :tags, :favorite)
                """, values)
            else:
                db.execute("""
                    UPDATE bookmarks
                    SET title=:title, url=:url, notes=:notes,
                        tags=:tags, favorite=:favorite
                    WHERE id=:id
                """, {**values, "id": bookmark_id})

    except ValueError as error:
        abort(400, str(error))
    except sqlite3.IntegrityError:
        abort(409, "A bookmark with that exact URL already exists.")

    flash("Bookmark saved.")
    return redirect(url_for("index"))


@app.post("/bookmarks/<int:bookmark_id>/<action>")
def change_bookmark(bookmark_id, action):
    if action not in {"favorite", "delete"}:
        abort(404)

    find_bookmark(bookmark_id)

    with get_db() as db:
        if action == "favorite":
            db.execute(
                "UPDATE bookmarks SET favorite=1-favorite WHERE id=?",
                (bookmark_id,),
            )
        else:
            db.execute(
                "DELETE FROM bookmarks WHERE id=?", (bookmark_id,)
            )

    return redirect(url_for("index"))


@app.get("/export")
def export_bookmarks():
    records = []
    for row in get_db().execute("""
        SELECT title, url, notes, tags, favorite
        FROM bookmarks ORDER BY id
    """):
        record = dict(row)
        record["tags"] = (
            record["tags"].split(",") if record["tags"] else []
        )
        record["favorite"] = bool(record["favorite"])
        records.append(record)

    return app.response_class(
        json.dumps(
            {"version": 1, "bookmarks": records},
            ensure_ascii=False,
            indent=2,
        ),
        mimetype="application/json",
        headers={
            "Content-Disposition":
                'attachment; filename="bookmarks.json"'
        },
    )


@app.post("/import")
def import_bookmarks():
    upload = request.files.get("file")
    if not upload:
        abort(400, "Choose a JSON export file.")

    try:
        payload = json.load(upload)
        if not isinstance(payload, dict) or payload.get("version") != 1:
            raise ValueError("Expected a version 1 bookmark export.")

        records = payload.get("bookmarks")
        if not isinstance(records, list) or len(records) > 2000:
            raise ValueError("Import at most 2,000 bookmarks at a time.")

        cleaned = [validate_bookmark(record) for record in records]

    except (ValueError, UnicodeError, RecursionError) as error:
        abort(400, f"Invalid import: {error}")

    added = 0
    with get_db() as db:
        for values in cleaned:
            added += db.execute("""
                INSERT INTO bookmarks
                    (title, url, notes, tags, favorite)
                VALUES (:title, :url, :notes, :tags, :favorite)
                ON CONFLICT(url) DO NOTHING
            """, values).rowcount

    flash(
        f"Imported {added}; "
        f"skipped {len(cleaned) - added} duplicate URLs."
    )
    return redirect(url_for("index"))

A few details deserve attention:

· g keeps each request’s SQLite connection separate, and teardown closes it. · WAL mode improves overlap between readers and a writer. SQLite still permits only one writer at a time. · Every record is validated before an import transaction begins. One invalid record rejects the entire import. · ON CONFLICT(url) DO NOTHING skips duplicate URLs without replacing existing notes or favorites. · Login clears the old session. The next page receives a fresh CSRF token. · URL validation rejects schemes such as javascript:. HTML escaping alone would not make those links safe.

Search uses literal substring matching, so % and _ have no special meaning. SQLite’s built-in lower() handles ASCII case folding; broader Unicode search can be a later improvement.

Create the interface

Jinja escapes bookmark content by default. Keep that behavior: do not apply |safe to titles, notes, tags, or URLs.

Create templates/base.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Personal Bookmarks</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
  <header>
    <h1><a href="{{ url_for('index') }}">Personal Bookmarks</a></h1>
    {% if session.get('authenticated') %}
    <nav>
      <a href="{{ url_for('export_bookmarks') }}">Export JSON</a>
      <form method="post" action="{{ url_for('logout') }}">
        <input type="hidden" name="csrf" value="{{ csrf_token() }}">
        <button>Log out</button>
      </form>
    </nav>
    {% endif %}
  </header>

  <main>
    {% for message in get_flashed_messages() %}
      <p role="status">{{ message }}</p>
    {% endfor %}

    {% block content %}{% endblock %}
  </main>
</body>
</html>

Create templates/login.html:

{% extends 'base.html' %}
{% block content %}
<section>
  <h2>Log in</h2>
  {% if error %}
    <p role="alert">{{ error }}</p>
  {% endif %}
  <form method="post">
    <input type="hidden" name="csrf" value="{{ csrf_token() }}">
    <label>
      Password
      <input type="password" name="password"
             autocomplete="current-password" required autofocus>
    </label>
    <button>Log in</button>
  </form>
</section>
{% endblock %}

Create templates/index.html:

{% extends 'base.html' %}
{% block content %}
<section>
  <h2>{{ 'Edit bookmark' if editing else 'Add bookmark' }}</h2>
  <form method="post"
        action="{{ url_for('save_bookmark', bookmark_id=editing.id) if editing else url_for('save_bookmark') }}">
    <input type="hidden" name="csrf" value="{{ csrf_token() }}">

    <label>
      Title
      <input name="title" maxlength="200"
             value="{{ editing.title if editing else '' }}" required>
    </label>

    <label>
      URL
      <input type="url" name="url" maxlength="2048"
             value="{{ editing.url if editing else '' }}"
             placeholder="https://example.com" required>
    </label>

    <label>
      Tags, separated by commas
      <input name="tags"
             value="{{ editing.tags if editing else '' }}"
             placeholder="python, reference">
    </label>

    <label>
      Notes
      <textarea name="notes" maxlength="4000"
                rows="3">{{ editing.notes if editing else '' }}</textarea>
    </label>

    <label class="check">
      <input type="checkbox" name="favorite"
             {% if editing and editing.favorite %}checked{% endif %}>
      Favorite
    </label>

    <button>Save bookmark</button>
    {% if editing %}
      <a href="{{ url_for('index') }}">Cancel</a>
    {% endif %}
  </form>
</section>

<section>
  <h2>Your library</h2>
  <form method="get" action="{{ url_for('index') }}">
    <label>
      Search title, URL, notes, or tags
      <input name="q" value="{{ q }}" maxlength="200">
    </label>
    <label>
      Exact tag
      <input name="tag" value="{{ tag }}" maxlength="32">
    </label>
    <label class="check">
      <input type="checkbox" name="favorites" value="1"
             {% if favorites %}checked{% endif %}>
      Favorites only
    </label>
    <button>Filter</button>
    <a href="{{ url_for('index') }}">Reset</a>
  </form>

  {% for bookmark in bookmarks %}
  <article>
    <h3>
      <a href="{{ bookmark.url }}">{{ bookmark.title }}</a>
      {% if bookmark.favorite %} ★{% endif %}
    </h3>
    <p class="url">{{ bookmark.url }}</p>
    <p class="notes">{{ bookmark.notes }}</p>

    <p>
      {% for item in bookmark.tags.split(',') if item %}
        <a class="tag"
           href="{{ url_for('index', tag=item) }}">{{ item }}</a>
      {% endfor %}
    </p>

    <div class="actions">
      <a href="{{ url_for('index', edit=bookmark.id) }}">Edit</a>

      <form method="post"
            action="{{ url_for('change_bookmark', bookmark_id=bookmark.id, action='favorite') }}">
        <input type="hidden" name="csrf" value="{{ csrf_token() }}">
        <button>
          {{ 'Unfavorite' if bookmark.favorite else 'Favorite' }}
        </button>
      </form>

      <form method="post"
            action="{{ url_for('change_bookmark', bookmark_id=bookmark.id, action='delete') }}">
        <input type="hidden" name="csrf" value="{{ csrf_token() }}">
        <label class="check">
          <input type="checkbox" required>
          Confirm deletion
        </label>
        <button>Delete</button>
      </form>
    </div>
  </article>
  {% else %}
    <p>No bookmarks match. Add one or change the filters.</p>
  {% endfor %}

  <nav class="pagination">
    {% if page > 1 %}
      <a href="{{ url_for('index', q=q, tag=tag, favorites='1' if favorites else '0', page=page-1) }}">
        Previous
      </a>
    {% endif %}

    <span>Page {{ page }}</span>

    {% if has_next %}
      <a href="{{ url_for('index', q=q, tag=tag, favorites='1' if favorites else '0', page=page+1) }}">
        Next
      </a>
    {% endif %}
  </nav>
</section>

<section>
  <h2>Import a JSON export</h2>
  <p>Maximum 2 MiB and 2,000 bookmarks. Existing URLs are kept as they are.</p>
  <form method="post" action="{{ url_for('import_bookmarks') }}"
        enctype="multipart/form-data">
    <input type="hidden" name="csrf" value="{{ csrf_token() }}">
    <label>
      Export file
      <input type="file" name="file"
             accept="application/json,.json" required>
    </label>
    <button>Import</button>
  </form>
</section>
{% endblock %}

Add static/style.css:

:root {
  font: 16px/1.5 system-ui, sans-serif;
  color-scheme: light dark;
}

body {
  margin: 0;
  background: Canvas;
  color: CanvasText;
}

main {
  max-width: 900px;
  margin: auto;
  padding: 1rem;
}

header, nav, .actions {
  display: flex;
  align-items: center;
  gap: 1rem;
  flex-wrap: wrap;
}

header {
  justify-content: space-between;
}

h1 {
  font-size: 1.6rem;
}

h1 a {
  color: inherit;
  text-decoration: none;
}

section, article {
  border: 1px solid #8887;
  border-radius: .6rem;
  padding: 1rem;
  margin: 1rem 0;
}

label {
  display: block;
  margin: .7rem 0;
}

input:not([type=checkbox]), textarea {
  box-sizing: border-box;
  width: 100%;
  padding: .6rem;
  font: inherit;
}

button {
  padding: .5rem .8rem;
  cursor: pointer;
  font: inherit;
}

.check {
  display: flex;
  align-items: center;
  gap: .4rem;
}

.notes {
  white-space: pre-wrap;
}

.url, .notes, h3 {
  overflow-wrap: anywhere;
}

.tag {
  display: inline-block;
  margin-right: .5rem;
}

[role=status] {
  border-left: 4px solid #39864b;
  padding: .7rem;
}

Every mutation uses a POST form containing a CSRF token. Search uses GET, making filtered views bookmarkable.

Run locally

From the project root:

flask --app app run --host 127.0.0.1 --port 8000

Open:

http://127.0.0.1:8000

Log in using your configured password, then add a bookmark.

A useful daily workflow is to save a short note explaining why a link matters and assign a few consistent tags, such as python, reference, or read-later.

The development server is suitable for local iteration. The Docker setup below uses Gunicorn.

Add Docker and persistent storage

Create .dockerignore:

.env
.venv/
__pycache__/
data/
.git/
*.sqlite3

Create Dockerfile:

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

RUN groupadd --gid 10001 app \
    && useradd --uid 10001 --gid app --no-create-home app \
    && mkdir /data \
    && chown app:app /data

COPY --chown=app:app app.py ./
COPY --chown=app:app templates ./templates
COPY --chown=app:app static ./static

USER app
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "1", "--threads", "4", "--worker-tmp-dir", "/tmp", "app:app"]

Create compose.yaml:

services:
  bookmarks:
    build: .
    ports:
      - "127.0.0.1:8000:8000"
    env_file: .env
    environment:
      DATABASE_PATH: /data/bookmarks.sqlite3
    volumes:
      - bookmark-data:/data
    restart: unless-stopped
    init: true
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    healthcheck:
      test:
        - CMD
        - python
        - -c
        - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3)"
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

volumes:
  bookmark-data:

Stop the development server first, then start Docker:

docker compose up -d --build
docker compose ps

Follow logs when needed:

docker compose logs -f bookmarks

The application listens on all container interfaces, while Docker publishes its port only on your computer’s loopback interface.

The entire /data directory is persisted because SQLite WAL mode can create -wal and -shm files alongside the database.

Your native development database and Docker database use separate storage. JSON export/import can transfer small libraries between them.

Test the application

Check that the server can read its database:

curl --fail http://127.0.0.1:8000/healthz

Expected response:

{"status":"ok"}

An unauthenticated export request should redirect to login:

curl -s -o /dev/null -w '%{http_code}\n' \
  http://127.0.0.1:8000/export

Expected status:

302

A POST without a CSRF token should fail:

curl -s -o /dev/null -w '%{http_code}\n' \
  --data-urlencode "title=Blocked request" \
  --data-urlencode "url=https://example.com" \
  http://127.0.0.1:8000/bookmarks

Expected status:

403

Test login, creation, and export with curl

The login form itself requires CSRF protection. This sequence preserves cookies and reads the token from each relevant page.

Run it against a fresh library, or use a URL you have not saved already:

bookmark_base="http://127.0.0.1:8000"
bookmark_tmp=$(mktemp -d)

extract_csrf() {
  python -c 'import re, sys; print(re.search("name=\"csrf\" value=\"([^\"]+)\"", sys.stdin.read())[1])'
}

curl -fsS -c "$bookmark_tmp/cookies" "$bookmark_base/login" > "$bookmark_tmp/login.html"
bookmark_token=$(extract_csrf < "$bookmark_tmp/login.html")

read -r -s -p "App password: " bookmark_password
printf '\n'

curl -fsS -o /dev/null \
  -b "$bookmark_tmp/cookies" -c "$bookmark_tmp/cookies" \
  --data-urlencode "csrf=$bookmark_token" \
  --data-urlencode "password=$bookmark_password" \
  "$bookmark_base/login"
unset bookmark_password

curl -fsS \
  -b "$bookmark_tmp/cookies" -c "$bookmark_tmp/cookies" \
  "$bookmark_base/" > "$bookmark_tmp/index.html"
bookmark_token=$(extract_csrf < "$bookmark_tmp/index.html")

curl -fsS -o /dev/null \
  -b "$bookmark_tmp/cookies" -c "$bookmark_tmp/cookies" \
  --data-urlencode "csrf=$bookmark_token" \
  --data-urlencode "title=Python documentation" \
  --data-urlencode "url=https://docs.python.org/3/" \
  --data-urlencode "tags=python, reference" \
  --data-urlencode "notes=Official language and standard-library reference." \
  "$bookmark_base/bookmarks"

curl -fsS -b "$bookmark_tmp/cookies" "$bookmark_base/export"

rm -rf "$bookmark_tmp"

The export has this structure:

{
  "version": 1,
  "bookmarks": [
    {
      "title": "Python documentation",
      "url": "https://docs.python.org/3/",
      "notes": "Official language and standard-library reference.",
      "tags": ["python", "reference"],
      "favorite": false
    }
  ]
}

Also verify these behaviors in the browser:

Test Expected behavior Save the same URL twice HTTP 409; original bookmark remains Save a javascript: URL HTTP 400 Edit notes and toggle favorite Changes remain after refresh Search for a title or note fragment Matching bookmark appears Click a tag Exact tag filter applies Import an export twice Existing URLs are skipped Import one valid and one invalid record HTTP 400; neither is added Recreate the Docker container Saved bookmarks remain

To check container persistence:

docker compose up -d --force-recreate

Understand the JSON transfer limits

JSON export includes the whole library. Import accepts this application’s versioned JSON format, with at most 2,000 bookmarks and a total HTTP request size of 2 MiB, including multipart overhead.

A large export can exceed those import limits. Use the SQLite procedure below for full-library backup and restoration.

Back up and restore the full library

Copying only a live SQLite database file can miss changes still stored in its WAL file. Python’s SQLite backup API creates a consistent snapshot while the application runs.

These commands target the Docker deployment.

Create a backup

docker compose exec -T bookmarks python - <<'PY'
import os
import sqlite3
from contextlib import closing

with closing(sqlite3.connect(os.environ["DATABASE_PATH"])) as source:
    with closing(
        sqlite3.connect("/data/bookmarks-backup.sqlite3")
    ) as destination:
        source.backup(destination)

print("SQLite snapshot created.")
PY
docker compose cp \
  bookmarks:/data/bookmarks-backup.sqlite3 \
  ./bookmarks-backup.sqlite3
chmod 600 bookmarks-backup.sqlite3

The copied file is independent of the Docker volume. Store it securely: it contains your URLs, tags, and notes.

For a native installation, the same backup API works with data/bookmarks.sqlite3 as the source.

Restore a backup

Restoration replaces the current library. Create a fresh backup first if you might need the current contents.

Stop the application:

docker compose stop bookmarks

Restore through a temporary file, checking database integrity before copying its contents into the application database:

docker compose run --rm --no-deps -T bookmarks python -c '
import os
import shutil
import sqlite3
import sys
import tempfile
from contextlib import closing

with tempfile.NamedTemporaryFile(dir="/data") as snapshot:
    shutil.copyfileobj(sys.stdin.buffer, snapshot)
    snapshot.flush()

    with closing(
        sqlite3.connect(
            f"file:{snapshot.name}?mode=ro", uri=True
        )
    ) as source:
        if source.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
            raise SystemExit("Backup failed integrity check.")

        with closing(
            sqlite3.connect(os.environ["DATABASE_PATH"])
        ) as destination:
            source.backup(destination)
' < bookmarks-backup.sqlite3

Start the application again:

docker compose up -d
curl --fail http://127.0.0.1:8000/healthz

Design decisions

Decision Reason and tradeoff Flask and server-rendered HTML Small dependency set; forms work without JavaScript SQLite on local storage Simple operation and backup for a personal library One Gunicorn worker, four threads Modest concurrency with a separate database connection per request WAL mode and five-second lock timeout Improves reader/writer overlap and tolerates brief write contention Exact URL uniqueness Preserves meaningful query strings and fragments; slash variants remain distinct Comma-separated internal tags Compact implementation for a small library; a tag table would suit richer tag management Literal substring search Predictable searches, including % and _; scans become slower as the library grows No metadata fetching Saving links requires no outbound requests and avoids server-side URL-fetching risks Signed session cookies Holds authentication state and CSRF tokens; bookmark contents stay in SQLite Transactional import Invalid records reject the batch; duplicate URLs preserve existing data Loopback port publishing Keeps the default deployment accessible from the host computer

The database and configuration are ordinary files, without application-level encryption. Disk access controls and device encryption protect stored data.

Common issues and troubleshooting

Problem Cause and fix Startup reports missing secrets Configure a SECRET_KEY of at least 32 characters and a password of at least 12 Login appears to succeed but returns to login Use COOKIE_SECURE=false for local HTTP; use the same hostname consistently A form returns HTTP 403 Its CSRF token is stale or missing; reload after login, logout, or cookie changes A custom hostname returns HTTP 400 Add the hostname to TRUSTED_HOSTS, then recreate the container Port 8000 is occupied Stop the native server, or change the mapping to 127.0.0.1:8080:8000 A bind-mounted directory returns permission errors The container runs as UID 10001; ensure that UID can write there, or use the named volume SQLite reports “database is locked” Keep transactions short and the database on local storage; avoid network filesystems JSON import returns HTTP 413 The request exceeded 2 MiB; use smaller transfers or restore a SQLite snapshot Case-insensitive search misses some non-English text SQLite’s built-in case folding is ASCII-focused; consider Unicode-aware search Development bookmarks do not appear in Docker Native development and Docker use separate databases

Changing .env requires a restart. For Docker:

docker compose up -d --force-recreate

Changing SECRET_KEY invalidates existing sessions. Rotate it when changing a compromised password so already-issued login cookies stop working.

docker compose down

retains the named volume. Adding -v removes the volume and its database.

For remote access, use an SSH tunnel or add an HTTPS reverse proxy with login rate limiting. Configure the actual hostname in TRUSTED_HOSTS and set COOKIE_SECURE=true when serving through HTTPS.

What you learned

You built a useful personal application while practicing:

· Validated CRUD operations with parameterized SQLite queries. · Password authentication, signed sessions, and CSRF protection. · Server-rendered forms with safely escaped user content. · Transactional imports and explicit duplicate handling. · Docker deployment with a nonroot user and persistent storage. · Consistent SQLite backup and restoration.

Possible next steps

· Add a bookmarklet: Open the add form with the current page’s title and URL. · Import browser bookmarks: Parse the Netscape HTML bookmark format. · Add SQLite FTS5: Introduce ranked full-text search as the library grows. · Add undo or trash: Make accidental deletion recoverable. · Track reading status: Add unread, reading, and finished states. · Schedule backups: Rotate SQLite snapshots and copy them to separate storage. · Improve form errors: Preserve submitted fields and display validation messages beside them.