Anyone who has ever tried to keep track of hundreds of bookmarks across multiple browsers and devices knows how quickly the problem gets out of hand. Linkding is a minimalist, open-source bookmark manager designed for self-hosters who want a fast, searchable archive of links without syncing data to a third-party cloud. It supports tagging, full-text search, browser extensions, bulk import from Netscape HTML exports, and a clean REST API — all in a single container with a sub-second response time even at thousands of entries. This guide walks you through a complete production deployment of Linkding using Docker Compose, Caddy as the reverse proxy with automatic TLS, and PostgreSQL as the persistent datastore, all on Ubuntu 22.04 or 24.04.
Architecture and flow overview
The stack consists of three services managed by Docker Compose:
- Linkding (app container) — the Django-based application server, exposing the bookmark manager UI and REST API on an internal port (9090 by default)
- PostgreSQL — the relational database storing all bookmarks, tags, snapshots, and user records; the default SQLite backend is replaced for durability and concurrent-user support
- Caddy — the reverse proxy handling inbound HTTPS, automatic certificate provisioning via ACME/Let's Encrypt, and forwarding requests to the Linkding container
All three services communicate over a private Docker network (linkding_net). PostgreSQL data is persisted on a named volume. Caddy terminates TLS on ports 80 and 443 and proxies HTTP to the Linkding service. No database port is exposed to the host. Linkding itself stores no secret credentials in the container filesystem; all sensitive values are injected at runtime from a .env file that Docker Compose sources automatically.
Prerequisites
- Ubuntu 22.04 or 24.04 server with a public IP
- A DNS A record for your bookmark domain (e.g.,
links.example.com) pointing to the server IP - Docker Engine and Docker Compose v2 installed (
docker compose versionshould print v2.x) - Ports 80 and 443 open in your firewall (
ufw allow 80/tcp && ufw allow 443/tcp) - A strong randomly generated password for the PostgreSQL user (use
openssl rand -hex 20)
Step-by-step deployment
1. Create the project directory and environment file
mkdir -p /opt/linkding && cd /opt/linkding
touch .envEdit .env with your values. Never commit this file to version control:
POSTGRES_DB=linkding
POSTGRES_USER=linkding_user
POSTGRES_PASSWORD=change_me_strong_password
LD_DB_ENGINE=postgres
LD_DB_HOST=postgres
LD_DB_PORT=5432
LD_DB_DATABASE=linkding
LD_DB_USER=linkding_user
LD_DB_PASSWORD=change_me_strong_password
LD_SUPERUSER_NAME=admin
LD_SUPERUSER_PASSWORD=change_me_admin_password
LD_ENABLE_AUTH_PROXY=False2. Write the Docker Compose file
version: "3.9"
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
env_file: .env
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- linkding_net
linkding:
image: sissbruecker/linkding:latest
restart: unless-stopped
env_file: .env
depends_on:
- postgres
networks:
- linkding_net
ports:
- "127.0.0.1:9090:9090"
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- linkding
networks:
- linkding_net
networks:
linkding_net:
volumes:
postgres_data:
caddy_data:
caddy_config:3. Write the Caddyfile
links.example.com {
reverse_proxy 127.0.0.1:9090
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
}Replace links.example.com with your actual domain. Caddy will automatically obtain and renew a Let's Encrypt certificate on first startup.
4. Start the stack
cd /opt/linkding
docker compose up -dWatch the startup logs until all three services report healthy:
docker compose logs -fConfiguration and secrets handling
All secrets live in /opt/linkding/.env. Restrict its permissions immediately after creation:
chmod 600 /opt/linkding/.env
chown root:root /opt/linkding/.envNever hard-code passwords in docker-compose.yml. The env_file: directive passes all variables from .env to both the postgres and linkding containers. If your infrastructure uses a secrets manager (Vault, Infisical), replace the .env file injection with a startup script that writes the file from the secrets API before calling docker compose up.
Linkding's superuser credentials set via LD_SUPERUSER_NAME and LD_SUPERUSER_PASSWORD are applied only on first run. To rotate the admin password after initial setup, use the Django management command instead:
docker compose exec linkding python manage.py changepassword adminVerification
After startup, confirm the deployment is fully functional:
# Confirm all three containers are running
docker compose ps
# Check Linkding responds on localhost
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:9090/
# Check Caddy obtained a certificate (look for certificate path)
docker compose logs caddy | grep -i "certificate\|tls\|obtained"
# Verify the public HTTPS endpoint
curl -s -o /dev/null -w "%{http_code}" https://links.example.com/
# Confirm PostgreSQL is accepting connections
docker compose exec postgres psql -U linkding_user -d linkding -c "SELECT count(*) FROM bookmarks_bookmark;"A 200 response from the HTTPS endpoint and a valid row count from PostgreSQL confirm the stack is production-ready.
Common issues and fixes
- Linkding shows SQLite-related errors instead of using PostgreSQL — Confirm all
LD_DB_*variables are set in.envand that theLD_DB_ENGINE=postgresline is present. If you previously ran Linkding with SQLite, the old database file at/etc/linkding/data/db.sqlite3inside the container may conflict. Remove any legacy volume mounts that map the SQLite path. - Caddy returns a TLS error or certificate provisioning fails — Confirm the DNS A record for
links.example.comresolves to the server's public IP (dig +short links.example.com) and that port 80 is open for the ACME HTTP-01 challenge. Caddy needs port 80 to complete certificate issuance even if you plan to use HTTPS-only traffic afterward. - PostgreSQL container restarts in a loop — Check logs with
docker compose logs postgres. A common cause is a mismatchedPOSTGRES_USERvalue between the initial volume creation run and a subsequent run with a changed username. Remove thepostgres_datavolume and let PostgreSQL reinitialize:docker compose down -v && docker compose up -d. This destroys all data, so only do this during initial setup. - Linkding's browser extension cannot reach the server — Ensure the CORS headers are not blocking the extension origin. Linkding does not require additional CORS configuration by default, but the Caddy
headerblock must not includeAccess-Control-Allow-Origin: noneoverrides. The security headers in this guide are safe for extension use. - Import of large Netscape bookmark HTML files times out — Linkding processes imports synchronously. For files with more than 5,000 entries, increase Caddy's read timeout and Linkding's Gunicorn worker timeout by adding
timeouts read 120sto the Caddyfile server block and setting the environment variableLD_REQUEST_TIMEOUT=120.
FAQ
Can I use Linkding without PostgreSQL and keep the default SQLite backend?
Yes. Remove the postgres service from Compose and all LD_DB_* variables from .env, and Linkding will fall back to its bundled SQLite database stored in the container's data directory. SQLite works well for a single user but is not recommended for multi-user setups or heavy concurrent bookmark additions due to write-lock contention. For production teams, PostgreSQL is the right choice.
How do I create additional user accounts after deployment?
Linkding supports multi-user mode. Create accounts via the admin panel at https://links.example.com/admin using your superuser credentials. Alternatively, use the management command: docker compose exec linkding python manage.py createsuperuser. Regular (non-admin) users can also be created from the Django admin panel and will only see their own bookmarks.
Does Linkding support browser extension sync?
Yes. Official browser extensions are available for Chrome, Firefox, and Safari on the Linkding GitHub releases page. Configure the extension with your instance URL (https://links.example.com) and an API token generated from your Linkding profile settings page under Integrations. The extension adds a toolbar button that bookmarks the current tab with one click.
How do I back up the Linkding database?
Run a PostgreSQL dump from the running container and archive it offsite:
docker compose exec postgres pg_dump -U linkding_user linkding | gzip > /opt/linkding/backups/linkding_$(date +%F).sql.gzAdd this command to a daily cron job and ship the output to S3 or Backblaze B2 using Rclone or Kopia. Linkding's SQLite export (available from the UI under Settings > General > Export) can serve as a human-readable bookmark backup in Netscape HTML format, but it is not a substitute for a full database backup when using PostgreSQL.
Can I enable full-text search over bookmark snapshots?
Linkding supports archiving full HTML snapshots of bookmarked pages and searching their content. Enable it by setting LD_ENABLE_SNAPSHOT=True in your .env file. Snapshots are stored in the container's data directory, so mount a Docker volume at /etc/linkding/data to persist them across container restarts. Be aware that snapshot storage can grow quickly for large bookmark libraries; monitor disk usage with docker system df.
How do I upgrade Linkding to a new version?
Linkding follows standard Docker image versioning. To upgrade without data loss:
cd /opt/linkding
docker compose pull
docker compose up -dDjango migrations run automatically on startup. Always take a database backup before pulling a major version bump. Check the Linkding GitHub releases page for breaking changes before upgrading across major versions.
How do I expose Linkding's REST API for external integrations?
Linkding's REST API is available at /api/bookmarks/ and uses token authentication. Generate a token from your profile settings under Integrations > REST API. Pass it in requests as: Authorization: Token <your-api-token>. The API supports full CRUD operations on bookmarks and tags, making it easy to integrate with tools like n8n, Zapier, or custom scripts for bulk imports and exports.
Internal links
- Production Guide: Deploy Authentik with Docker Compose + Caddy + PostgreSQL + Redis on Ubuntu
- Production Guide: Deploy Vaultwarden with Docker Compose + Caddy on Ubuntu
- Production Guide: Deploy Nextcloud with Docker Compose + Caddy + PostgreSQL on Ubuntu
Talk to us
If you want this deployed with hardened access controls, monitoring standards, and production runbooks tailored to your environment, our team can help end-to-end.