Skip to Content

Production Guide: Deploy Linkding with Docker Compose + Caddy + PostgreSQL on Ubuntu

Self-host a fast, private bookmark manager with automatic HTTPS and a production-grade PostgreSQL backend

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 version should 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 .env

Edit .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=False

2. 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 -d

Watch the startup logs until all three services report healthy:

docker compose logs -f

Configuration 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/.env

Never 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 admin

Verification

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 .env and that the LD_DB_ENGINE=postgres line is present. If you previously ran Linkding with SQLite, the old database file at /etc/linkding/data/db.sqlite3 inside 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.com resolves 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 mismatched POSTGRES_USER value between the initial volume creation run and a subsequent run with a changed username. Remove the postgres_data volume 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 header block must not include Access-Control-Allow-Origin: none overrides. 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 120s to the Caddyfile server block and setting the environment variable LD_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.gz

Add 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 -d

Django 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

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.

Contact Us

Production Guide: Deploy Shlink with Docker Compose + Caddy + PostgreSQL on Ubuntu
Self-host a branded URL shortener with automatic HTTPS, REST API, and click analytics