Self-host LinkBreeze: a Linktree alternative
Run LinkBreeze on a VPS with Docker Compose and Caddy: pinned image tags, cookieless click tracking, and the one volume that holds your whole site.
What LinkBreeze is
LinkBreeze is a self-hosted Linktree alternative: one Docker container that serves a public link-in-bio page and an admin dashboard, with every piece of state in a single SQLite file. It is MIT licensed, written in TypeScript on Next.js, and published as ghcr.io/manak-hash/linkbreeze. To run it you need a VPS, a domain with an A record pointing at that VPS, ports 80 and 443 open, and Docker Engine with the Compose plugin.
This guide covers the deployment the repository actually supports: Docker Compose behind a reverse proxy that fetches its own certificates. It also covers what breaks, because a link in a bio is a public URL other people click, and a broken one costs you the click.
Before any of that, be clear about how new this project is.
Is LinkBreeze mature enough for a public profile link?
As of August 2026 the repository has 178 stars, 17 forks and a single maintainer. The first tagged release, v1.0.0, is dated 1 July 2026. This is a project a few weeks old, not a few years old.
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 the project has shipped 17 tagged releases across 7 calendar weeks. The last week in that chart was still running when this guide was written and already held 3 of them.
Read that as two separate facts. The maintainer is active and bugs get fixed within days. The schema and the defaults are also still moving, so an instance you deploy and forget will drift a long way from the code being written.
The license protects you from the worst case. MIT plus a container image plus a SQLite file on your own disk means that if development stops, what you have keeps running. What it does not protect you from is a public-facing web app that stops receiving security fixes, which becomes a liability over time. Deploy this as something you will keep updating, and keep the backup routine below working from day one.
Pin the image tag, and do not run latest
The release workflow pushes exactly two tags per version: latest, and the version number with the leading v removed. The pinned tag for release v1.2.7 is therefore ghcr.io/manak-hash/linkbreeze:1.2.7. Writing :v1.2.7 pulls nothing and Docker reports manifest unknown, because that tag was never pushed.
Pin it because latest moves. At the cadence in the chart above, a docker compose pull against latest is an unreviewed upgrade of a page your audience is using. With a pinned tag, an upgrade happens when you edit the file.
One more thing about the image. The release workflow builds with no platforms: setting, so the published image is linux/amd64 only. On an arm64 host the pull fails with no matching manifest for linux/arm64/v8 in the manifest list entries. If you run an ARM VPS rather than x86, build the image on the box instead:
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 in the compose file below.
Deploy LinkBreeze behind Caddy with automatic TLS
Caddy requests and renews certificates from Let's Encrypt on its own, so TLS (transport layer security) needs no separate certificate step. The whole deployment is three files in one directory.
Generate the secret first:
mkdir -p ~/linkbreeze && cd ~/linkbreeze
printf 'SECRET_KEY=%s\n' "$(openssl rand -hex 32)" > .env
chmod 600 .envSECRET_KEY signs the admin session cookie and salts the analytics visitor hash. The compose file published in the repository defaults it to ${SECRET_KEY:-changeme-in-production}, so an instance where you skip this step runs with a session signing key that is printed in public on GitHub. Set it before the first start, because changing it later logs you out and resets 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 is optional and worth setting: it tells the app its real public address, so a request arriving with a forged Host header cannot make the app generate links to somebody else's domain.
Write Caddyfile next to it, with your own domain:
links.example.com {
encode zstd gzip
reverse_proxy linkbreeze:3000
}Caddy sets X-Forwarded-For and X-Forwarded-Proto on proxied requests by default, which the analytics depend on. Bring it up:
docker compose up -d
docker compose ps
docker compose logs -f caddydocker compose ps should show the LinkBreeze container as healthy. The image ships its own healthcheck, wget --spider -q http://127.0.0.1:3000/api/health, so you do not need to add one. Do not copy the healthcheck out of the repository's own Caddy example: it calls curl, and the image is built on node:22-alpine, which carries busybox wget and no curl. That container reports unhealthy while serving pages perfectly well.
Open https://links.example.com in a browser. The first visit lands on the setup wizard at /setup, which creates the single admin account. After that the dashboard is at /dashboard and the login form at /login.
Notice what the compose file does not do: it never publishes port 3000. Only Caddy listens on the public interface. If Compose file syntax is new, the Docker Compose basics for a VPS covers the parts this file assumes, and if you already run something else in front, Nginx, Caddy and Traefik compared explains what changes. The repository ships working examples for Nginx with Certbot, Traefik and a Cloudflare tunnel.
Where your data lives, and what a backup has to contain
DATABASE_PATH points at /app/data/linkbreeze.db. Uploaded avatars and link thumbnails are written beside it in /app/data/uploads. Both live in the named volume linkbreeze-data, so the unit of backup is the volume, not the database file on its own. Restore the file without the uploads directory and every image on the page returns a 404.
Everything else really is in that one database: pages, links, settings, theme, email subscribers and analytics rows.
Take the copy with the container stopped:
docker compose stop linkbreeze
docker compose cp linkbreeze:/app/data ./backup-$(date +%F)
docker compose start linkbreezeStop first because copying a SQLite database while a process is writing to it can capture a half-finished transaction, and the copy then opens as a corrupt file. The page is offline while the copy runs. Restoring is the same move in reverse:
docker compose stop linkbreeze
docker compose cp ./backup-2026-08-14/. linkbreeze:/app/data
docker compose start linkbreeze
docker compose logs -f linkbreezeThe dashboard also offers a JSON export, served from /api/backup as linkbreeze-backup-YYYY-MM-DD.json. It carries the profile, the links, the settings and the saved themes. It does not carry analytics history, email subscribers or uploaded images, and restoring it deletes the current rows in those four tables before inserting the file's. Treat it as a config snapshot for moving hosts or undoing an editing mistake. The volume copy is the backup.
Two storage rules apply here the same as anywhere else you are running SQLite in production on a VPS. Keep the database on local disk, because SQLite's locking is unreliable on a network filesystem and a corrupt page is how you find out. And if you swap the named volume for a host bind mount, chown the host directory first: the container runs as the non-root node user, uid 1000 in node:22-alpine, and a directory created by root is not writable by it, so the app cannot open the database and the container exits at startup. Bind mounts against named volumes in Compose covers that trade in full.
The analytics, and the consent banner you do not need
This is the feature that justifies self-hosting a page you could get free elsewhere.
The analytics are cookieless. No cookie is set for a visitor and no third-party script loads on the public page. A visitor is identified by a SHA-256 hash of the IP address, the user agent string and a salt, truncated to 16 hexadecimal characters. The salt is itself a hash of the current UTC date and your SECRET_KEY, so it changes at midnight UTC and yesterday's hashes cannot be matched against today's. The raw IP address is never written to the database.
Clicks are counted on the server. Every http link on the public page points at /go/<id> on your own domain, which records the click and then answers with a 302 redirect to the real destination. Counting therefore works for readers with JavaScript disabled, and inside the in-app browsers that block background requests. Page views are recorded through /api/track.
Two exclusions are worth knowing. A request carrying a valid admin session is skipped, so editing your own page does not inflate the numbers. Known crawler user agents are skipped too.
On consent: nothing is stored on the reader's device, and a cookie stored on the reader's device is the specific thing a cookie banner asks permission for. Your obligations still depend on where your readers live, so check them, but there is no tracking cookie here to disclose and no third party receiving the data.
One caveat that surprises people: rotate SECRET_KEY and the daily salt changes with it, so every returning visitor is counted as new from that moment.
Why is the analytics country column empty?
Because nothing in your stack sets a country header. LinkBreeze resolves the country from proxy headers such as cf-ipcountry and x-vercel-ip-country. On a VPS behind your own Caddy or Nginx, none of those headers exist, so the country is recorded as null and the breakdown stays blank. There is no GeoIP database inside the container.
Two ways to fill it. Put Cloudflare in front of the domain, which adds cf-ipcountry to every request it proxies. Or set one of those headers in your own reverse proxy from a local GeoIP lookup.
The related trap is worse, so check it. The click and view handlers read the client address from X-Forwarded-For first, then X-Real-IP, and fall back to 0.0.0.0 when neither header is present. Publish port 3000 straight to the internet with no proxy in front and every visitor hashes to the same value, which means unique visitors reads 1 forever, and the per-IP rate limit of 60 events per minute applies to your entire audience at once. Behind the reverse_proxy directive above, Caddy sets the header for you and both problems disappear.
Import from Linktree, and what does not come across
The migration wizard in the dashboard accepts a public profile URL or an exported file. It recognises 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 a Linktree or Bento URL it reads the __NEXT_DATA__ JSON those pages embed. For a static page it reads the anchor tags.
What comes across is the title, the URL, the description and the image of each link, whether the link is a social profile, and your display name, bio and avatar. You pick which of the found links to keep before anything is written to the database.
What does not come across is the analytics history, the theme and layout, email subscribers, scheduled publish dates, and anything the old platform keeps behind its own login. Plan on rebuilding the look by hand, and accept that the old click history stays on the old service.
The importer fetches the URL from your server rather than from your browser, so it refuses addresses that are not public. Private/local URLs are not allowed means you gave it an address inside your own network, and the refusal is deliberate: without it, anyone with dashboard access could use your server to probe machines that only your server can reach. The other messages you may see are Only http and https URLs are allowed, Request timed out and Response too large.
Scraping depends on somebody else's markup. If the wizard finds nothing on a page that clearly has links, that platform changed its HTML since the parser was written. Add the links by hand rather than waiting for a fix. If what you actually want is measurable short links rather than a profile page, a self-hosted URL shortener like Shlink does that job and runs happily on the same box.
Updating a pinned deployment
# edit the image tag in docker-compose.yml, then
docker compose pull
docker compose up -d
docker compose logs -f linkbreezeSchema migrations run automatically when the container starts. There is no documented way to run them backwards, so take the volume copy first. An upgrade you cannot reverse is only safe when you can restore what was there before it.
The dashboard shows a banner when a newer release exists. It checks by fetching a small version file from the project's GitHub repository once every 24 hours, and it sends nothing about your instance. Read the release notes before you move the tag, because at this stage of the project a minor version can change defaults you rely on.
Failure modes and the strings you will see
manifest unknown when pulling. The tag was written as :v1.2.7. Registry tags carry no v, so use :1.2.7.
no matching manifest for linux/arm64/v8 in the manifest list entries. The published image is amd64 only. Build it on the ARM host from the tagged source.
The container reports unhealthy while the page loads fine. A healthcheck in your compose file is calling curl, which the image does not contain. Delete it and let the image's own wget healthcheck run.
Caddy serves a certificate error, or nothing at all. Check docker compose logs caddy. The usual causes are an A record that does not point at this VPS yet, or port 80 closed on the firewall, which blocks the ACME (automatic certificate management environment) HTTP challenge that Caddy uses to prove it controls the domain.
Unique visitors is stuck at 1. No proxy is setting X-Forwarded-For, so every visitor hashes identically.
The container exits right after startup, having worked yesterday. If you moved from a named volume to a host bind mount, the data directory is owned by root and the app runs as uid 1000, so it cannot open the database file. sudo chown -R 1000:1000 the host directory.
Tracking requests answered with HTTP 429. The per-IP throttle on /api/track and /go/<id> was hit. Visitors are still redirected to their destination, the click is simply not counted.
FAQ
Is LinkBreeze ready for a public link in bio?
It is a young project. As of August 2026 the repository holds 178 stars, 17 forks and one maintainer, and the first release is dated 1 July 2026. Releases land more than twice a week on average, so bugs are fixed quickly and behaviour changes quickly too. The MIT license and the local SQLite file mean you keep a working page even if development stops, but a public web app without security fixes turns into a liability, so treat this as software you will keep updating rather than install once.
Which LinkBreeze image tag should I run?
Run the version tag, for example ghcr.io/manak-hash/linkbreeze:1.2.7, and change it deliberately. The release workflow pushes only latest and the bare version number, so :v1.2.7 with the v does not exist and Docker answers manifest unknown. The image is built for linux/amd64 only, so on an arm64 VPS you must clone the tag and build locally.
Why does the country breakdown stay empty in LinkBreeze analytics?
LinkBreeze reads the visitor country from proxy headers such as cf-ipcountry or x-vercel-ip-country, and it carries no GeoIP database of its own. A VPS behind your own Caddy or Nginx sets none of those headers, so the country is stored as null. Put Cloudflare in front of the domain, or have your reverse proxy set one of those headers from a local GeoIP lookup.
What exactly do I back up, and how do I restore it?
Back up the whole linkbreeze-data volume, not just the database file. /app/data/linkbreeze.db holds every link, page, setting, subscriber and analytics row, and /app/data/uploads holds the avatar and thumbnail images that the page references. Stop the container, run docker compose cp linkbreeze:/app/data ./backup-$(date +%F), then start it again. Restore by copying the directory back into the stopped container and starting it. The JSON export from the dashboard is a config snapshot of profile, links, settings and themes, and it contains no analytics and no images.
Does importing from Linktree bring my analytics and theme with it?
No. The migration wizard reads the link titles, URLs, descriptions and images from your old public profile, plus your display name, bio and avatar. Analytics history, the theme, email subscribers and scheduled publish dates stay behind. Rebuild the look in the theme editor after the import, and expect your click history to remain on the old platform.