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.