Skip to content
Go back

LittleLink vs LinkStack: Ditch Linktree

By KingPin 12 min read
LittleLink vs LinkStack: Ditch Linktree
Contents

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

LittleLinkLittleLink-ServerLinkStack
What it isStatic HTML and CSSNext.js app, env-var configPHP/Laravel app, admin panel
LicenseMITMITAGPL-3.0
Latest release I usedv3.11.0 (2026-07-29)latest imagev4.8.6 (2026-02-17)
ConfigEdit index.htmlEnvironment variablesWeb UI
Multi-userNoNoYes
Click statsNoOptional (Google Analytics, Umami, Matomo)Built in
Image size66.3 MB (caddy:2-alpine) plus 1.1 MB of site files204 MB279 MB
Idle RAM (two runs)17 to 37 MiB54 to 75 MiB45 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 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:

index.html
<html class="theme-auto" lang="en">

Each button is one anchor tag. Change the href, the label, and the button-* class:

index.html
<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:

fetch.sh
git clone --depth 1 --branch v3.11.0 https://github.com/sethcottle/littlelink.git site
rm -rf site/.git

Then serve ./site with Caddy. The Caddyfile has three directives:

Caddyfile
{$SITE_ADDRESS::80} {
root * /srv
encode zstd gzip
file_server
}
compose.yaml
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:
Terminal window
HTTP_PORT=18080 docker compose up -d
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' localhost:18080/
# 200 31818

The 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 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.

compose.yaml
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-stopped

The 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:

compose.yaml
services:
linkstack:
hostname: linkstack
image: linkstackorg/linkstack:latest
environment:
TZ: UTC
SERVER_ADMIN: [email protected]
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:

Terminal window
git clone --depth 1 --branch v3.11.0 https://github.com/sethcottle/littlelink.git /tmp/ll-new
diff -u site/index.html /tmp/ll-new/index.html | head -40

You 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.

Terminal window
docker compose pull
docker compose up -d

LinkStack 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:

Terminal window
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.

  1. 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.
  2. Only you edit it, and you want config as code in a compose file: LittleLink-Server. Pay the Next.js tax knowingly.
  3. 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

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.

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.

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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Near-Zero-Downtime Postgres Major Upgrades
Next Post
PHP2Shell: Is Your Homelab Running PHP?

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts