SSD Nodes Learn 🎉 VPS from $5.50/mo
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-15

How to self-host LinkBreeze with Docker Compose

Run LinkBreeze for your own link-in-bio page with Docker Compose and Caddy. Pin image tags, enable cookieless click tracking, and back up one SQLite volume.

Wetin be LinkBreeze

LinkBreeze na self-hosted alternative to Linktree: one Docker container wey dey serve public link-in-bio page and admin dashboard, with all state for one SQLite file. MIT license cover am, dem write am for TypeScript on Next.js, and dem publish am as ghcr.io/manak-hash/linkbreeze. To run am, you need VPS, domain wey get A record pointing to that VPS, ports 80 and 443 wey dey open, plus Docker Engine with Compose plugin.

This guide cover the deployment wey the repository actually support: Docker Compose behind reverse proxy wey dey fetch its own certificates. E also cover wetin fit break, because link for bio na public URL wey other people go click, and broken link fit make you lose the click.

Before all this, understand say this project still new well-well.

As of August 2026, repository get 178 stars, 17 forks, and one maintainer. The first tagged release, v1.0.0, get date of 1 July 2026. This project just dey a few weeks old, e no be project wey don dey for some years.

ChartLinkBreeze tagged releases per week, v1.0.0 to v1.2.7
The data behind this chart
[
  {
    "week": "2026-06-29",
    "releases": 3,
    "cumulative": 3
  },
  {
    "week": "2026-07-06",
    "releases": 3,
    "cumulative": 6
  },
  {
    "week": "2026-07-13",
    "releases": 1,
    "cumulative": 7
  },
  {
    "week": "2026-07-20",
    "releases": 2,
    "cumulative": 9
  },
  {
    "week": "2026-07-27",
    "releases": 3,
    "cumulative": 12
  },
  {
    "week": "2026-08-03",
    "releases": 2,
    "cumulative": 14
  },
  {
    "week": "2026-08-10",
    "releases": 3,
    "cumulative": 17
  }
]

Since v1.0.0, project don release 17 tagged releases across 7 calendar weeks. The last week for that chart never finish when dem write this guide, and e already get 3 of the releases.

Take this as two separate facts. The maintainer dey active, and dem dey fix bugs within days. But schema and defaults still dey change, so instance wey you deploy and forget go gradually differ well well from the code wey dem dey write.

The license protect you from the worst case. MIT, plus a container image and a SQLite file for your own disk, mean say if development stop, wetin you get go continue to run. But e no protect you from public-facing web app wey stop receiving security fixes. Over time, that one fit become liability. Deploy this as something wey you go continue to update, and make the backup routine below dey work from day one.

Pin image tag, and no run latest

Release workflow dey push exactly two tags for each version: latest, and version number wey remove leading v. So, pinned tag for release v1.2.7 na ghcr.io/manak-hash/linkbreeze:1.2.7. If you write :v1.2.7, e no go pull anything, and Docker go report manifest unknown, because dem never push that tag.

Pin am because latest dey change. With the schedule for the chart above, one docker compose pull against latest na upgrade wey nobody review for page wey your audience dey use. With pinned tag, upgrade go happen when you edit the file.

One more thing about the image. Release workflow dey build without platforms: setting, so published image na linux/amd64 only. For arm64 host, the pull go fail with no matching manifest for linux/arm64/v8 in the manifest list entries. If you dey run ARM VPS instead of x86, build the image for the server itself:

git clone --branch v1.2.7 --depth 1 https://github.com/Manak-hash/LinkBreeze.git
cd LinkBreeze
docker build -t linkbreeze:1.2.7 .

Then use linkbreeze:1.2.7 as the image name for the compose file below.

Deploy LinkBreeze dey behind Caddy with automatic TLS

Caddy dey request and renew certificates from Let's Encrypt by itself, so TLS (transport layer security) no need separate certificate step. The whole deployment na three files for one directory.

Generate the secret first:

mkdir -p ~/linkbreeze && cd ~/linkbreeze
printf 'SECRET_KEY=%s\n' "$(openssl rand -hex 32)" > .env
chmod 600 .env

SECRET_KEY dey sign the admin session cookie and salt the analytics visitor hash. The compose file wey dey published for the repository set am to ${SECRET_KEY:-changeme-in-production} by default, so instance wey you skip this step go run with session signing key wey dem publish for GitHub. Set am before the first start, because if you change am later, e go log you out and reset the analytics salt.

Write docker-compose.yml:

services:
  linkbreeze:
    image: ghcr.io/manak-hash/linkbreeze:1.2.7
    restart: unless-stopped
    volumes:
      - linkbreeze-data:/app/data
    environment:
      - DATABASE_PATH=/app/data/linkbreeze.db
      - SECRET_KEY=${SECRET_KEY}
      - BASE_URL=https://links.example.com
    networks:
      - linkbreeze-net

  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
    networks:
      - linkbreeze-net

networks:
  linkbreeze-net:

volumes:
  linkbreeze-data:
  caddy-data:
  caddy-config:

BASE_URL optional and e good make you set am: e tell the app the real public address, so request wey arrive with forged Host header no fit make the app generate links to another person domain.

Write Caddyfile beside am, with your own domain:

links.example.com {
    encode zstd gzip
    reverse_proxy linkbreeze:3000
}

Caddy dey set X-Forwarded-For and X-Forwarded-Proto for proxied requests by default, and analytics depend on dem. Bring am up:

docker compose up -d
docker compose ps
docker compose logs -f caddy

docker compose ps suppose show the LinkBreeze container as healthy. The image get its own healthcheck, wget --spider -q http://127.0.0.1:3000/api/health, so you no need add another one. No copy the healthcheck from the repository own Caddy example: e dey call curl, and the image build on node:22-alpine, wey get busybox wget but no get curl. That container dey report unhealthy even when e dey serve pages well.

Open https://links.example.com for browser. The first visit go land on setup wizard for /setup, wey go create the only admin account. After that, dashboard dey for /dashboard and login form dey for /login. That account local to this instance, and the app no get single sign-on hook. So if you want the dashboard to use the same login as everything else wey you host, you need put forward auth proxy for front of am, such as self-hosted Authentik.

Notice wetin the compose file no do: e never publish port 3000. Na only Caddy dey listen on the public interface. If Compose file syntax new to you, Docker Compose basics for a VPS cover the parts wey this file assume. If you already dey run something else for front, Nginx, Caddy and Traefik compared explain wetin you need change. The repository ship working examples for Nginx with Certbot, Traefik and a Cloudflare tunnel.

Wia your data dey, and wetin backup must carry

DATABASE_PATH dey point to /app/data/linkbreeze.db. Uploaded avatars and link thumbnails dey write beside am for /app/data/uploads. Both dey inside the named volume linkbreeze-data, so the backup unit na the volume, no be the database file alone. If you restore the file without the uploads directory, every image for the page go return 404.

Everything else dey inside that one database: pages, links, settings, theme, email subscribers, and analytics rows.

Take the copy when container don stop:

docker compose stop linkbreeze
docker compose cp linkbreeze:/app/data ./backup-$(date +%F)
docker compose start linkbreeze

Stop am first because if process dey write to SQLite database while you copy am, the copy fit capture transaction wey never finish, and the copy go open as corrupt file. The page no go dey available while the copy dey run. To restore am, na the same process for reverse:

docker compose stop linkbreeze
docker compose cp ./backup-2026-08-14/. linkbreeze:/app/data
docker compose start linkbreeze
docker compose logs -f linkbreeze

The dashboard also get JSON export, wey e serve from /api/backup as linkbreeze-backup-YYYY-MM-DD.json. E carry the profile, links, settings, and saved themes. E no carry analytics history, email subscribers, or uploaded images. When you restore am, e delete the current rows for those four tables before e insert the rows from the file. Treat am as config snapshot for moving hosts or undoing editing mistake. The volume copy na the backup.

Two storage rules apply here the same way dem apply anywhere else you dey run SQLite for production on a VPS. Keep the database for local disk, because SQLite locking no reliable for network filesystem, and na corrupt page go show you the result. If you replace the named volume with host bind mount, chown the host directory first: the container dey run as the non-root node user, uid 1000 for node:22-alpine, and directory wey root create no writable for am, so the app no fit open the database and the container go exit for startup. Bind mounts against named volumes for Compose explain that trade fully.

Na this feature dey justify to self-host page wey you fit get free for another place.

The analytics no dey use cookies. E no set any cookie for visitor, and no third-party script dey load for public page. The system identify visitor with SHA-256 hash of the IP address, user agent string, and salt, then e truncate am to 16 hexadecimal characters. The salt na hash of current UTC date and your SECRET_KEY, so e change for midnight UTC, and dem no fit match yesterday hashes with today own. Raw IP address no dey ever write to database.

Server dey count clicks. Every http link for public page point to /go/<id> for your own domain. E record the click, then e answer with 302 redirect go the real destination. So counting still work for readers wey disable JavaScript, and for in-app browsers wey block background requests. Page views dey record through /api/track.

Two exclusions dey important to know. System skip request wey carry valid admin session, so editing your own page no go increase the numbers. E also skip known crawler user agents.

About consent: nothing dey store for reader device. Cookie wey dey store for reader device na the exact thing wey cookie banner dey ask permission for. Your obligations still depend on where your readers dey live, so check dem. But no tracking cookie dey here to disclose, and no third party dey receive the data.

One caveat wey fit surprise people: if you rotate SECRET_KEY, the daily salt go change too. From that moment, system go count every returning visitor as new.

Why analytics country column dey empty?

Because nothing for your stack dey set country header. LinkBreeze dey resolve country from proxy headers like cf-ipcountry and x-vercel-ip-country. If VPS dey behind your own Caddy or Nginx, none of those headers dey exist, so system go record country as null and breakdown go remain blank. GeoIP database no dey inside the container.

You get two ways to fill am. Put Cloudflare in front of the domain; e go add cf-ipcountry to every request wey e proxy. Or set one of those headers for your own reverse proxy from local GeoIP lookup.

The related trap worse, so check am. The click and view handlers first dey read client address from X-Forwarded-For, then X-Real-IP, and dem go use 0.0.0.0 if both headers no dey present. If you publish port 3000 directly to internet without proxy in front, every visitor go hash to the same value. This means unique visitors go dey read 1 forever, and per-IP rate limit of 60 events per minute go apply to your whole audience at the same time. Behind the reverse_proxy directive above, Caddy go set the header for you, and both problems go disappear.

Import wey come from Linktree, and wetin no go come across

Migration wizard for dashboard dey accept public profile URL or exported file. E recognise linktr.ee, bento.me, lnk.bio, tap.link, hopp.bio, beacons.ai, solo.to, linkfly, mssg.me and LittleLink pages, plus generic HTML and JSON exports. For Linktree or Bento URL, e dey read the __NEXT_DATA__ JSON wey those pages embed. For static page, e dey read the anchor tags.

Wetin go come across na title, URL, description and image of each link, whether the link na social profile, plus your display name, bio and avatar. You go pick which of the links wey e find you wan keep before e write anything to database.

Wetin no go come across na analytics history, theme and layout, email subscribers, scheduled publish dates, and anything wey the old platform keep behind im own login. Plan to rebuild the appearance by hand, and accept say old click history go remain for the old service.

Importer dey fetch the URL from your server, no be from your browser, so e dey reject addresses wey no public. Private/local URLs are not allowed mean say you give am address inside your own network, and the rejection na deliberate: without am, anybody wey get dashboard access fit use your server probe machines wey na only your server fit reach. Other messages wey you fit see na Only http and https URLs are allowed, Request timed out and Response too large.

Scraping depend on another person markup. If wizard no find anything for page wey clearly get links, that platform don change im HTML since dem write the parser. Add the links by hand instead of waiting for fix. If wetin you really want na measurable short links instead of profile page, self-hosted URL shortener like Shlink fit do that work and e go run well for the same box.

Pinned deployment update kɛ

# edit the image tag in docker-compose.yml, then
docker compose pull
docker compose up -d
docker compose logs -f linkbreeze

Schema migrations dey run automatically when container start. No documented way dey to run dem backwards, so make you first take copy of the volume. Upgrade wey you no fit reverse only safe when you fit restore wetin dey there before.

Dashboard go show banner when newer release dey available. E dey check by fetching small version file from project GitHub repository once every 24 hours, and e no dey send anything about your instance. Read release notes before you change the tag, because for this stage of the project, minor version fit change defaults wey you dey rely on.

Wahala wey fit happen and the strings wey you go see

manifest unknown when pulling. Dem write the tag as :v1.2.7. Registry tags no get v, so use :1.2.7.

no matching manifest for linux/arm64/v8 in the manifest list entries. The published image na amd64 only. Build am for the ARM host from the tagged source.

The container reports unhealthy while the page dey load fine. A healthcheck for your compose file dey call curl, but the image no contain am. Delete am and make the image own wget healthcheck run.

Caddy dey serve certificate error, or e no dey serve anything. Check docker compose logs caddy. The common causes na A record wey never point to this VPS, or port 80 wey firewall close. This one blocks the ACME (automatic certificate management environment) HTTP challenge wey Caddy dey use to prove say e control the domain.

Unique visitors remain for 1. No proxy dey set X-Forwarded-For, so every visitor hash the same way.

The container dey exit immediately after startup, even though e work yesterday. If you move from named volume go host bind mount, root own the data directory, while the app dey run as uid 1000. So e no fit open the database file. sudo chown -R 1000:1000 the host directory.

Tracking requests answer with HTTP 429. The per-IP throttle for /api/track and /go/<id> don reach limit. Visitors still dey redirect to their destination; na the click counting no happen.

FAQ

Na young project. As of August 2026, repository get 178 stars, 17 forks and one maintainer, and dem date the first release 1 July 2026. Releases dey come out more than twice every week on average, so dem dey fix bugs quickly, but behaviour fit change quickly too. MIT license and the local SQLite file mean say you go still get working page even if development stop. But public web app wey no dey receive security fixes fit become liability, so treat am as software wey you go keep updating, no be something wey you install once.

Which LinkBreeze image tag I suppose run?

Run the version tag, for example ghcr.io/manak-hash/linkbreeze:1.2.7, and change am deliberately. Release workflow dey push only latest and the bare version number, so :v1.2.7 with the v no dey exist and Docker go return manifest unknown. Dem build the image for linux/amd64 only, so if na arm64 VPS you dey use, you must clone the tag and build am locally.

Why country breakdown for LinkBreeze analytics dey remain empty?

LinkBreeze dey read visitor country from proxy headers like cf-ipcountry or x-vercel-ip-country, and e no get GeoIP database of its own. VPS wey dey behind your own Caddy or Nginx no dey set any of those headers, so system go store the country as null. Put Cloudflare in front of the domain, or make your reverse proxy set one of those headers from local GeoIP lookup.

Wetin exactly I suppose back up, and how I go restore am?

Back up the whole linkbreeze-data volume, no be only the database file. /app/data/linkbreeze.db hold every link, page, setting, subscriber and analytics row, while /app/data/uploads hold the avatar and thumbnail images wey the page dey reference. Stop the container, run docker compose cp linkbreeze:/app/data ./backup-$(date +%F), then start am again. To restore, copy the directory back inside the stopped container and start am. The JSON export from dashboard na config snapshot of profile, links, settings and themes, and e no contain analytics or images.

Import from Linktree go bring my analytics and theme?

No. Migration wizard dey read link titles, URLs, descriptions and images from your old public profile, plus your display name, bio and avatar. Analytics history, theme, email subscribers and scheduled publish dates remain for the old platform. Rebuild the look with theme editor after the import, and expect your click history to remain for the old platform.

#linkbreeze#linktree-alternative#docker-compose#sqlite#self-hosting#analytics