You want one URL for your Instagram bio that points at your GitHub, your blog, and your tip jar. Linktree sells that for free, until you want your own branding gone. As of October 2026 the free tier gives you unlimited basic links, and “Remove Linktree branding” first shows up on Pro, at $12/mo billed annually or $15 monthly. That is $144 a year to hide a logo on a page of buttons.
A link-in-bio page is a list of buttons. You do not need a SaaS for that, and you probably do not need a database either. My opinion: if the page is yours alone, serve a static LittleLink page from Caddy (or any static host) and be done. LinkStack is the right pick when you host pages for several people or you want a web UI and click stats. LittleLink-Server sits in the middle.
Full example: Clone the working files at github.com/KingPin/sumguy-examples/self-hosting/littlelink-vs-linkstack
I stood up all three in Docker and measured them. The numbers below come from those runs on one Linux box with Docker 29.8.2. They are single observations of idle containers, not benchmarks.
The Three Contenders
| LittleLink | LittleLink-Server | LinkStack | |
|---|---|---|---|
| What it is | Static HTML and CSS | Next.js app, env-var config | PHP/Laravel app, admin panel |
| License | MIT | MIT | AGPL-3.0 |
| Latest release I used | v3.11.0 (2026-07-29) | latest image | v4.8.6 (2026-02-17) |
| Config | Edit index.html | Environment variables | Web UI |
| Multi-user | No | No | Yes |
| Click stats | No | Optional (Google Analytics, Umami, Matomo) | Built in |
| Image size | 66.3 MB (caddy:2-alpine) plus 1.1 MB of site files | 204 MB | 279 MB |
| Idle RAM (two runs) | 17 to 37 MiB | 54 to 75 MiB | 45 to 49 MiB |
Image sizes come from docker image ls. Idle RAM comes from docker stats --no-stream a few seconds after startup (LinkStack after 25 seconds, sitting on its setup wizard), across two separate runs. Treat the RAM column as “all three are small”. Your Raspberry Pi will not notice any of them.
One surprise: LinkStack idled at or below LittleLink-Server in both runs. I did not trace why, so treat it as an observation. LinkStack was idle on its setup wizard, and the gap will move once it has users and traffic.
LittleLink: The Static Page
LittleLink is a folder of HTML, CSS, and SVG icons. There is nothing to patch at 2 AM because there is no runtime to patch. The checkout I tested was 1.1 MB on disk, with 121 anchor tags in the stock index.html (most of them sample buttons you delete).
It ships three themes (auto, light, dark), about 90 branded button styles in css/brands.css, and a Button Builder for brands it does not cover. You switch themes by changing one class on the html tag:
<html class="theme-auto" lang="en">Each button is one anchor tag. Change the href, the label, and the button-* class:
<a class="button button-github" href="https://github.com/example" target="_blank" rel="noopener"> <img class="icon" aria-hidden="true" src="images/icons/github.svg" alt="GitHub Logo">GitHub</a>Your “admin panel” is a text editor and git commit.
Serve it with Caddy
Do not vendor the repo into your own. Pin a tag and fetch it:
git clone --depth 1 --branch v3.11.0 https://github.com/sethcottle/littlelink.git siterm -rf site/.gitThen serve ./site with Caddy. The Caddyfile has three directives:
{$SITE_ADDRESS::80} { root * /srv encode zstd gzip file_server}services: caddy: image: caddy:2-alpine ports: - "${HTTP_PORT:-8080}:80" environment: SITE_ADDRESS: ${SITE_ADDRESS:-:80} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./site:/srv:ro - caddy_data:/data restart: unless-stopped
volumes: caddy_data:HTTP_PORT=18080 docker compose up -dcurl -s -o /dev/null -w '%{http_code} %{size_download}\n' localhost:18080/# 200 31818The stock page weighed 31,818 bytes before compression, and css/brands.css came back with a 200. That was Caddy v2.11.7 in my run. For a real domain, set SITE_ADDRESS=links.example.com, publish port 443, and Caddy fetches and renews the certificate itself. If you already run Caddy for other things, skip the container and add a file_server block for the folder. Nginx, Cloudflare Pages, or a GitHub Pages repo work just as well. The repo even ships a wrangler.toml for Cloudflare.
The tradeoff: no click stats, no editing from your phone. If you want numbers, add your analytics snippet (a self-hosted Umami script works) to index.html.
LittleLink-Server: The Middle Option
LittleLink-Server takes the LittleLink look and builds the page from environment variables. No HTML editing. Change a variable, restart the container, and the page changes. The README states that values are read at request time, so no rebuild is needed.
services: littlelink-server: image: ghcr.io/timothystewart6/littlelink-server:latest environment: META_TITLE: Your Name META_DESCRIPTION: Links to everything I make LANG: en META_INDEX_STATUS: all THEME: Dark NAME: Your Name BIO: Homelab, Docker, and bad ideas AVATAR_URL: https://links.example.com/avatar.png AVATAR_ALT: Your Name BUTTON_ORDER: GITHUB,YOUTUBE,INSTAGRAM GITHUB: https://github.com/example YOUTUBE: https://youtube.com/@example INSTAGRAM: https://instagram.com/example ports: - "8082:3000" restart: unless-stoppedThe container listens on port 3000. This block is trimmed from the companion file, which adds a few more variables. I mapped it to 8082 and got a 200 with an 11,138-byte page, and my NAME value showed up in the HTML. BUTTON_ORDER lists the variable names you want boosted to the top, and the README documents CUSTOM_BUTTON_* variables for links the built-in brands do not cover.
Is it worth a Next.js container? For me, only if you want the config in a compose file and never want to touch HTML. It costs 204 MB of image and 54 to 75 MiB of RAM to do what a static file does for free. It earns a spot if you run a dozen small compose stacks and like everything declared in one place.
LinkStack: The Full App
LinkStack is a PHP/Laravel application. You get a web admin panel, multiple users, themes, click statistics, and a setup wizard. Each user gets their own page. That is the feature that matters: you can host pages for your band, your friends, or a small team and let each person edit their own.
The official image runs Apache with PHP on Alpine, and the Docker README says both HTTP and HTTPS are exposed by default. This is a trimmed version of the companion compose file (which puts the host ports in variables), based on the README example with port 80 added:
services: linkstack: hostname: linkstack image: linkstackorg/linkstack:latest environment: TZ: UTC HTTP_SERVER_NAME: links.example.com HTTPS_SERVER_NAME: links.example.com LOG_LEVEL: info PHP_MEMORY_LIMIT: 256M UPLOAD_MAX_FILESIZE: 8M volumes: - linkstack_data:/htdocs ports: - "8190:80" - "8191:443" restart: unless-stopped
volumes: linkstack_data:The README example publishes only 8190:443. I published both so I could test over plain HTTP. In production, put a reverse proxy in front and use HTTPS, which the README also recommends.
First start, in my run: the container answered with a 200 and a page titled “LinkStack setup”. The wizard lists “Setup the database” and “Create the admin user” as steps. The image ships with DB_CONNECTION=sqlite in its .env and a database.sqlite file already in /htdocs/database. I did not click through the wizard, so I cannot tell you from my own run what the MySQL path looks like. SQLite is fine for a link page. Keep the linkstack_data volume, because /htdocs holds the app, uploads, and the database.
The image weighed 279 MB, and version.json inside reported 4.8.6, matching the latest release when I checked. The idle container sat at 45 to 49 MiB while it waited at the wizard.
The costs are real. You now run a PHP application with accounts, a database, and updates. The README says new versions appear as an update notice in the admin panel. That is a web app with logins on the public internet, so you own its patching. A static file has no login to brute-force.
Keeping Each One Alive
A link page that breaks is worse than no link page. Your bio points at it all day, and nobody tells you when it is down. The upkeep differs a lot between the three.
LittleLink has one moving part. Your index.html lives in a git repo, so your backup is git push. To update LittleLink itself, fetch a newer tag into a fresh folder and copy your edited index.html and images over. Because you only change one file, the diff is tiny:
git clone --depth 1 --branch v3.11.0 https://github.com/sethcottle/littlelink.git /tmp/ll-newdiff -u site/index.html /tmp/ll-new/index.html | head -40You can skip updates entirely for years. A folder of HTML does not rot the way a PHP dependency tree does.
LittleLink-Server updates by pulling the image and recreating the container. Your config is the compose file, so the backup is the compose file in git. The latest tag moves under you, so pin a digest or a version tag once you like how the page looks, or a surprise pull at 2 AM becomes your problem.
docker compose pulldocker compose up -dLinkStack has the most to protect. Everything lives in the linkstack_data volume mounted at /htdocs: the app, your uploads, and the SQLite file. Back up that volume, not just a compose file. A quick tarball through a throwaway container works without stopping anything fancy:
docker run --rm -v linkstack_data:/data -v "$PWD":/backup alpine \ tar czf /backup/linkstack-backup.tgz -C /data .Stop the container first if you want a clean copy of the SQLite file while nobody is saving changes. Then test the restore once, on a spare port, before you need it. An untested backup is a wish.
Which Buttons You Will Actually Use
Most people need five to eight links. Read the list before choosing a tool. If it is a personal page with a blog, a code host, a video channel, and a tip jar, any of the three handles it in minutes. Custom buttons are where the options diverge.
In LittleLink, a custom button means picking the closest button-* class or building one with the Button Builder and adding the result to css/brands.css. In LittleLink-Server, you add the CUSTOM_BUTTON_* variables the README documents (text, URL, color, icon), separated by commas. In LinkStack you add links in the admin panel, no files involved.
If you change links weekly, the panel wins. If you change them twice a year, editing one HTML file wins, and you avoid keeping a login alive for a page you rarely touch.
The Verdict
Pick by who edits the page.
- Only you edit it, a few times a year: LittleLink behind Caddy. Smallest image, smallest attack surface, free hosting anywhere. Your link page does not need a login form.
- Only you edit it, and you want config as code in a compose file: LittleLink-Server. Pay the Next.js tax knowingly.
- Several people, or you want edits from a browser and click stats: LinkStack. Its extra weight buys exactly those things.
Running LinkStack for one personal page is the forklift-and-couch move. It works, and your neighbors will have questions.
Moving off Linktree
There is no export button that produces a LittleLink page, so you copy your links by hand. Open your Linktree, list the URLs and labels, and paste them into anchor tags. For ten links that is about ten minutes. Then change the link in your Instagram or TikTok bio and leave the old Linktree up for a week, so cached links still resolve.
Pick a short domain or subdomain you control, like links.example.com. When you move hosts later, only the DNS record changes and every printed QR code and bio link keeps working.
Common Questions
Is LittleLink free?
Yes. LittleLink is MIT licensed, so you can use and modify it for free, including commercially. You pay only for hosting. A static host such as Cloudflare Pages or GitHub Pages costs nothing, and a small VPS or home server running Caddy handles the traffic without trouble.
Can I use a custom domain with LittleLink?
Yes. Point a DNS record for a name like links.example.com at the server running Caddy, set SITE_ADDRESS to that name, and publish port 443. Caddy requests and renews the TLS certificate automatically. On static hosts, you add the custom domain in the host’s dashboard instead.
Does LinkStack have click analytics?
Yes. LinkStack tracks clicks per link in its admin panel, which static LittleLink does not. LittleLink-Server accepts Google Analytics, Umami, or Matomo settings through environment variables. For a static LittleLink page, paste a self-hosted analytics script such as Umami into index.html.
How much RAM does LinkStack need?
LinkStack idled at 45 to 49 MiB in two test runs, before any real traffic or a configured database. The image sets a PHP memory limit of 256M by default through PHP_MEMORY_LIMIT. Any small VPS or Raspberry Pi runs it, and the 279 MB image is the larger cost.
Is a self-hosted link page bad for SEO?
No. A link-in-bio page exists for humans coming from a social profile, so search ranking rarely matters. Your main site still carries the SEO weight. LittleLink-Server exposes META_INDEX_STATUS to control whether search engines index the page. On static LittleLink, edit the robots and description meta tags in index.html yourself.