Can I self-host Linktree?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called LinkStack. It takes one prompt, a 512 MB VPS, and about 10 minutes. That is $15 a month you stop paying Linktree — $180 a year on the Pro plan.

  • linktr.ee
  • Marketing & content
  • prices checked 2026-08-06

Why people pay for Linktree

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Linktree sells one URL that never has to change and never goes down, on the day a post goes further than anyone expected. Around that it sells the things a page of links cannot do by itself: click and audience analytics, a checkout for digital products, mailing-list capture, scheduling, and the removal of its own branding. For most subscribers the money buys not thinking about any of it, which is a real product even though the page itself is a list of links.

Linktree plans and list prices
PlanList priceWhat it buys
FreefreeUnlimited basic links, Linktree branding stays, and digital-product sales carry the highest seller fee.
Starter$8/mo$6/month equivalent when billed annually. Adds personalisation and basic analytics.
Prothe plan this page prices against$15/mo$12/month equivalent when billed annually. Removes Linktree branding and adds advanced analytics and mailing-list integrations.
Premium$35/mo$30/month equivalent when billed annually. Adds zero-fee digital product sales, data export and social scheduling.
Agency and Enterprisequote onlyQuote only. The page lists team seats, SSO, dedicated support and custom contracts, with no figure attached.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium

Replaced by LinkStack

One project, named before the prompt, so you know what you are about to install.

Your own link-in-bio page, with an admin panel, themes and a QR code, on a domain you keep.

The only one of these that gives you the part people actually pay for beyond the links: an admin panel where you add a link, pick a theme and get a QR code without editing a file or redeploying anything. It is one container with SQLite inside it, so the install is a compose file and a five-screen setup wizard. What it does not replace is Linktree's commerce: no checkout, no affiliate shop, no seller payouts, and per-link click counts rather than audience analytics.

The swap

You're paying

Linktree

$15/mo · $180/yr

is replaced by

You'd run

LinkStack

ONE COMMAND · ~10 min to running · 512 MB RAM

Linktree Pro · vendor list price · checked 2026-08-06 · source · confidence: medium

Before you start

RAM floor
512 MBfloor from upstream docs — not measured by us yet
Disk
5 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~10 minunder 10 minutes, through the first backup

The prompt

Two paths to the same LinkStack: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

323 lines · 14,994 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install LinkStack 4.8.6 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Say why when you ask: this hostname goes on every profile link and QR code they hand out, and
moving it later means reprinting whatever the code is on. Its A record must already point at
this server.

LinkStack needs 512 MB of RAM available and 5 GB free on /srv. The image publishes amd64,
arm64 and two 32-bit arm variants. Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 512 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a hostname that does not resolve.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/linkstack /srv/linkstack/backups
ls -la /srv/linkstack
```

Assert: `ls -la` shows `backups` owned by the login user. Two files live here, the compose file
and the environment file step 3 writes. The application, its themes, its uploads and its SQLite
database sit in a Docker volume instead, because the image ships the application at /htdocs and
Docker fills an empty named volume from the image while a bind mount would leave it empty.
Step 8 is how those files leave it.

## 3. Secrets

No secret is generated for this install and there is no `openssl rand` here. LinkStack's only
credential is the administrator account, created in a browser at step 7. What this step writes
is configuration: the hostname Apache answers to, which the compose file reads.

Write this with `<DOMAIN>` replaced by the real hostname on both lines:

```bash
umask 077
cat > /srv/linkstack/.env <<'EOF'
HTTP_SERVER_NAME=<DOMAIN>
HTTPS_SERVER_NAME=<DOMAIN>
EOF
chmod 600 /srv/linkstack/.env
umask 022
cat /srv/linkstack/.env
```

Assert: `cat` prints exactly two lines, both carrying the real hostname, and no `<DOMAIN>`
survives anywhere in the file.

Tell the user there are two files called `.env` here and they are different. This one is
Compose's and carries the Apache server name; the application keeps its own at /htdocs inside
the container, which steps 7 and 8 reach with `docker compose exec`.

## 4. compose.yml

```bash
cat > /srv/linkstack/compose.yml <<'EOF'
# LinkStack · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker setup ....... https://docs.linkstack.org/docker/setup/
#   reverse proxies .... https://docs.linkstack.org/docker/reverse-proxies/
#   image build ........ https://github.com/LinkStackOrg/linkstack-docker/blob/main/Dockerfile
#   app configuration .. https://github.com/LinkStackOrg/LinkStack/blob/v4.8.6/.env
#
# One container: Alpine, Apache 2 and PHP 8.3, carrying LinkStack 4.8.6 and the
# SQLite file it keeps links and users in. There is no database service here.
#
# /htdocs is a named volume and not a bind mount, and that is not a style
# choice. The image ships the application at /htdocs, and Docker copies image
# content into an empty named volume only, never into a bind mount, so a bind
# mount would start the container on an empty document root. Upstream documents
# that path as downloading the release yourself and giving the files uid 100
# and gid 101, which this install does not do.
#
# The pin is a digest with no tag, because upstream publishes no version tags:
# linkstackorg/linkstack carries a rolling tag plus four architecture tags from
# 2023. This is the manifest list digest read from Docker Hub on 2026-08-06.
# /htdocs/version.json inside it reads 4.8.6, and the list covers linux/amd64,
# linux/arm/v6, linux/arm/v7 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  linkstack:
    image: linkstackorg/linkstack@sha256:1c8b05399ee459ac601bac3eede7fbe765d1b6b7be725663b57f3220610958bf
    container_name: linkstack
    restart: unless-stopped
    # HTTP_SERVER_NAME and HTTPS_SERVER_NAME are Apache's ServerName. They carry
    # the hostname, so they live in .env beside this file rather than in it.
    env_file: /srv/linkstack/.env
    environment:
      TZ: UTC
      # Apache's log level. The image default is info, which narrates every
      # start-up; warn keeps the container log to lines worth reading.
      LOG_LEVEL: warn
      PHP_MEMORY_LIMIT: 256M
      UPLOAD_MAX_FILESIZE: 8M
    volumes:
      - linkstack-htdocs:/htdocs
    ports:
      # Loopback only: the host's Caddy is the one thing that reaches 8121, and
      # it reaches the container's plain HTTP port. The container also listens
      # on 443 with a self-signed certificate, and this file never publishes it.
      # The image's own health check is curl against http://localhost, so the
      # HTTP port is the one upstream tests too.
      - "127.0.0.1:8121:80"

volumes:
  linkstack-htdocs:
EOF
cd /srv/linkstack && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Do not add a second service. The database is a SQLite file
inside the same container, which is what makes this a one-container install.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-linkstack
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# LinkStack · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.linkstack.org/docker/reverse-proxies/,
# https://docs.linkstack.org/docker/setup/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also HTTP_SERVER_NAME in .env, and it is the address printed on the profiles
# and QR codes people scan, so it is the value here worth choosing once.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8121 is the loopback port compose publishes, and it maps to the
	# container's plain HTTP port. Upstream's own example proxies to the
	# container's 443 with certificate checking switched off; this block does
	# not, because that certificate is self-signed and skipping the check on
	# a hop is worse than not making the hop over TLS at all. The scheme the
	# application prints in its own pages comes from FORCE_HTTPS in the app's
	# .env, which step 7 turns on, so nothing here needs the back end to
	# speak TLS.
	reverse_proxy 127.0.0.1:8121
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-linkstack, reload, and report what it objected to. Caddy requests
the certificate on the first request and renews it on its own, so there is nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change
nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp
is HTTP/3. 8121 stays closed because compose binds it to 127.0.0.1, so a rule for it would
cover traffic that cannot arrive. Assert: `ufw status verbose` prints `Status: active`, shows
80, 443/tcp and 443/udp, and no rule mentioning 8121.

## 7. Start and verify

Read the whole block before running any of it. Between the container starting and the user
finishing the setup wizard, that wizard is open to whoever loads the page.

```bash
cd /srv/linkstack
docker compose pull
docker compose up -d
for i in $(seq 1 30); do state=$(docker inspect --format '{{.State.Health.Status}}' linkstack 2>/dev/null || echo starting); echo "$i $state"; [ "$state" = healthy ] && break; sleep 5; done
docker compose exec -T linkstack sed -i 's/^FORCE_HTTPS=false$/FORCE_HTTPS=true/' /htdocs/.env
docker compose exec -T linkstack grep '^FORCE_HTTPS=' /htdocs/.env
curl -sSL -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/ | grep -c 'Setup LinkStack'
```

Assert all four, and print what you received for each: the loop ends on `healthy`; the grep
prints `FORCE_HTTPS=true`, which is what makes the application write `https://` into pages
served through Caddy; the curl prints `200`; the last command prints a number greater than `0`.
If any misses, stop, run `docker compose logs --tail 40 linkstack`, and name the likely earlier
step: a `502` points at step 4 or 5, and a certificate error usually means the A record was
created minutes ago and Caddy is still retrying. A running container is not success.

The first screen at https://<DOMAIN> shows the heading `Setup LinkStack` and a language menu.

STOP: tell the user to open https://<DOMAIN> now and work through the five setup screens, and
wait. Do not continue until they confirm. Tell them the three answers that matter: choose
SQLite when it asks for a database type, use a password from their password manager on the
admin screen, and on the last screen set `Enable registration` to `No` unless they intend to
run a site other people sign up to. Tell them to do it now, not after lunch; step 10 says why.

Once they confirm, prove the install closed behind them:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/register
docker compose exec -T linkstack sh -c 'test ! -f /htdocs/INSTALLING && echo "installer removed"'
curl -sSL https://<DOMAIN>/login | grep -c 'Sign In'
docker compose exec -T linkstack sh -c "sed -i 's/^APP_DEBUG=true$/APP_DEBUG=false/; s/^APP_ENV=local$/APP_ENV=production/' /htdocs/.env"
docker compose exec -T linkstack grep -E '^APP_(DEBUG|ENV)=' /htdocs/.env
```

Assert, all four: `/register` prints `404`, the application's answer once registration is off
and the security check in this block; `installer removed` prints, so the setup routes are gone;
the login page count is greater than `0`; and the last grep prints `APP_DEBUG=false` and
`APP_ENV=production`, so a PHP error now shows a generic page rather than a stack trace with
file paths in it. All four must pass before you report success.

## 8. First backup and restore

Two artifacts: the data archive holds what the user made, the config archive the files that
rebuild the service around it.

```bash
cd /srv/linkstack
docker compose exec -T linkstack tar -czf - -C /htdocs .env database assets/img themes > /srv/linkstack/backups/linkstack-data-$(date +%F).tar.gz
sudo tar -czf /srv/linkstack/backups/linkstack-config-$(date +%F).tar.gz -C /srv/linkstack compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/linkstack/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. The data archive is small
because it leaves out the application code, which comes back from the pinned image. The `.env`
in it carries `APP_KEY`; without that key the sessions and password-reset links in a restored
database stop verifying, so it is the one file here that cannot be replaced.

A backup on the same disk is not a backup. Run this from the user's machine:

```bash
mkdir -p ~/backups/linkstack
scp vps:/srv/linkstack/backups/* ~/backups/linkstack/
```

To restore: `docker compose down -v`, which drops the volume on purpose, then
`docker compose up -d` to refill /htdocs from the image, wait for health, then
`docker compose exec -T linkstack tar -xzf - -C /htdocs < backups/linkstack-data-DATE.tar.gz`
with the real filename, then
`docker compose exec -T linkstack rm -f /htdocs/INSTALLING` because the fresh image brings the
setup marker back and the installer would otherwise be live again. Reload https://<DOMAIN> and
sign in. Those four commands are the whole disaster plan: the links, the theme and the uploaded
images are all in the one archive.

## 9. Updating later

Two things move here, separately, which is unusual enough to say out loud.

The application in /htdocs updates from inside. Upstream's documented path for this image is
the update notice in the admin panel and its one-click updater, which rewrites the files in the
volume. Take both archives from step 8 first, because that updater changes the application
underneath a running container.

The runtime, meaning Alpine, Apache and PHP, updates by digest. New digests appear at
https://hub.docker.com/r/linkstackorg/linkstack/tags after a release at
https://github.com/LinkStackOrg/LinkStack/releases. Edit the image line in
/srv/linkstack/compose.yml to the new digest, then:

```bash
cd /srv/linkstack
docker compose pull
docker compose up -d
docker compose logs --tail 20 linkstack
```

That replaces the container and leaves /htdocs alone, because Docker only fills a volume that
is empty. Re-run step 7's `curl` checks before calling the update done.

## 10. What will probably go wrong

The minute between step 7 starting the container and the user finishing the wizard is the one
genuinely dangerous minute in this install, and it does not look dangerous. I read the
installer's routes while writing this: while the setup marker file is present, every one of
them answers without a login, and one of them exists to skip the wizard by seeding an
administrator whose password is a fixed string published in the project's source. A hostname
that resolves publicly gets scanned within minutes of its first certificate. So do not start
the container and then go and make coffee. If the user cannot sit down with the form right now,
run `docker compose stop` and begin step 7 again when they can.

## 11. Out of scope

- Do not choose MySQL in the setup wizard. SQLite inside the container is what makes this one
  container and one archive; MySQL means a second service this prompt does not install.
- Do not configure SMTP. LinkStack ships a built-in mail setting that relays through a service
  the project runs, and changing it is a decision about someone else's terms.
- Do not run the in-app updater or the theme updater during this install. Both rewrite files in
  the volume, and step 8 has not run yet.
- Do not publish the container's 443 or open 8121 in the firewall. Caddy is the only way in.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing LinkStack 4.8.6 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box.

One thing to settle before step 1. `<DOMAIN>` is the address that ends up on every profile link
and QR code you hand out, and moving it later means reprinting whatever the code is printed on.
Pick the hostname you intend to keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `512` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. An IP that is not your
server's usually means a proxying CDN sits in front of the record; turn that off for this
hostname while you install, or the certificate is issued to somebody else's edge.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/linkstack /srv/linkstack/backups
ls -la /srv/linkstack
```

You should see: `backups`, owned by you, and nothing else yet.

If you do not: there is no `data` directory here on purpose, and no ownership to fix. The
application, its themes, its uploads and its SQLite database live in a Docker volume rather
than under /srv, because the image ships the application at /htdocs and Docker fills an empty
named volume from the image while a bind mount would leave that directory empty. Step 8 is how
those files leave the volume and land in `backups`, where you can copy them.

## 3. Secrets

Nothing is generated on this path, and there is no `openssl rand` anywhere in it. LinkStack's
only credential is the administrator account, and you create it in a browser at step 7. The
file below is configuration: the hostname Apache answers to, which the compose file reads.
Replace `<DOMAIN>` on both lines with your real hostname before you paste.

```bash
umask 077
cat > /srv/linkstack/.env <<'EOF'
HTTP_SERVER_NAME=<DOMAIN>
HTTPS_SERVER_NAME=<DOMAIN>
EOF
chmod 600 /srv/linkstack/.env
umask 022
cat /srv/linkstack/.env
```

You should see: exactly two lines, both carrying your hostname, and no `<DOMAIN>` left in
either.

If you do not: a `<DOMAIN>` that survived means Apache answers to a name that is not yours,
which mostly shows up as a wrong hostname in error pages rather than a broken site, so it is
worth fixing now rather than wondering later. Edit the file and run the `cat` again.

Do not paste the contents of any `.env` file, the administrator password you are about to
choose, or any command output containing either, back into this chat window. That applies twice
over to the application's own `.env` at /htdocs, which step 7 edits: it holds `APP_KEY`, the
value your sessions and password-reset links are signed with. There are two files called `.env`
in this install and they are not the same thing. The one you wrote above belongs to Docker
Compose. The application keeps its own inside the container.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/linkstack/compose.yml <<'EOF'
# LinkStack · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker setup ....... https://docs.linkstack.org/docker/setup/
#   reverse proxies .... https://docs.linkstack.org/docker/reverse-proxies/
#   image build ........ https://github.com/LinkStackOrg/linkstack-docker/blob/main/Dockerfile
#   app configuration .. https://github.com/LinkStackOrg/LinkStack/blob/v4.8.6/.env
#
# One container: Alpine, Apache 2 and PHP 8.3, carrying LinkStack 4.8.6 and the
# SQLite file it keeps links and users in. There is no database service here.
#
# /htdocs is a named volume and not a bind mount, and that is not a style
# choice. The image ships the application at /htdocs, and Docker copies image
# content into an empty named volume only, never into a bind mount, so a bind
# mount would start the container on an empty document root. Upstream documents
# that path as downloading the release yourself and giving the files uid 100
# and gid 101, which this install does not do.
#
# The pin is a digest with no tag, because upstream publishes no version tags:
# linkstackorg/linkstack carries a rolling tag plus four architecture tags from
# 2023. This is the manifest list digest read from Docker Hub on 2026-08-06.
# /htdocs/version.json inside it reads 4.8.6, and the list covers linux/amd64,
# linux/arm/v6, linux/arm/v7 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  linkstack:
    image: linkstackorg/linkstack@sha256:1c8b05399ee459ac601bac3eede7fbe765d1b6b7be725663b57f3220610958bf
    container_name: linkstack
    restart: unless-stopped
    # HTTP_SERVER_NAME and HTTPS_SERVER_NAME are Apache's ServerName. They carry
    # the hostname, so they live in .env beside this file rather than in it.
    env_file: /srv/linkstack/.env
    environment:
      TZ: UTC
      # Apache's log level. The image default is info, which narrates every
      # start-up; warn keeps the container log to lines worth reading.
      LOG_LEVEL: warn
      PHP_MEMORY_LIMIT: 256M
      UPLOAD_MAX_FILESIZE: 8M
    volumes:
      - linkstack-htdocs:/htdocs
    ports:
      # Loopback only: the host's Caddy is the one thing that reaches 8121, and
      # it reaches the container's plain HTTP port. The container also listens
      # on 443 with a self-signed certificate, and this file never publishes it.
      # The image's own health check is curl against http://localhost, so the
      # HTTP port is the one upstream tests too.
      - "127.0.0.1:8121:80"

volumes:
  linkstack-htdocs:
EOF
cd /srv/linkstack && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/linkstack/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/linkstack/compose.yml` and paste again in one go. Do not add a second service to
fix anything here. The database is a SQLite file inside the same container, which is what makes
this a one-container install and a single archive at step 8.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-linkstack
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# LinkStack · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.linkstack.org/docker/reverse-proxies/,
# https://docs.linkstack.org/docker/setup/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also HTTP_SERVER_NAME in .env, and it is the address printed on the profiles
# and QR codes people scan, so it is the value here worth choosing once.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8121 is the loopback port compose publishes, and it maps to the
	# container's plain HTTP port. Upstream's own example proxies to the
	# container's 443 with certificate checking switched off; this block does
	# not, because that certificate is self-signed and skipping the check on
	# a hop is worse than not making the hop over TLS at all. The scheme the
	# application prints in its own pages comes from FORCE_HTTPS in the app's
	# .env, which step 7 turns on, so nothing here needs the back end to
	# speak TLS.
	reverse_proxy 127.0.0.1:8121
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-linkstack /etc/caddy/Caddyfile`, reload,
and paste again. The most common cause is a `<DOMAIN>` you replaced in the comment but not in
the site line, or the other way round. Caddy requests the certificate on the first request and
renews it on its own, so there is nothing to schedule and no cron job to check later.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8121`.

If you do not: delete anything for `8121` with `sudo ufw delete allow 8121`. Compose binds that
port to 127.0.0.1, so a firewall rule for it would cover traffic that cannot arrive in the
first place. 80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way
in, and 443/udp is HTTP/3, which Caddy offers by default. `Status: inactive` is a different
problem: Prompt Zero left this firewall on, so something has turned it off since, and
`sudo ufw enable` puts it back before you go any further.

## 7. Start and verify

Read this whole step before you run any of it. Between the container starting and you finishing
the setup wizard, that wizard is open to whoever loads the page, so plan to do both in one
sitting.

```bash
cd /srv/linkstack
docker compose pull
docker compose up -d
for i in $(seq 1 30); do state=$(docker inspect --format '{{.State.Health.Status}}' linkstack 2>/dev/null || echo starting); echo "$i $state"; [ "$state" = healthy ] && break; sleep 5; done
docker compose exec -T linkstack sed -i 's/^FORCE_HTTPS=false$/FORCE_HTTPS=true/' /htdocs/.env
docker compose exec -T linkstack grep '^FORCE_HTTPS=' /htdocs/.env
curl -sSL -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/ | grep -c 'Setup LinkStack'
```

You should see, in order: the loop reaching `healthy`, then `FORCE_HTTPS=true`, then `200`,
then a number greater than `0`.

If you do not: a loop that never leaves `starting` means the container is not serving, so run
`docker compose logs --tail 40 linkstack`. A `502` from the curl means Caddy is reaching
nothing on 8121, which is step 4 or step 5. A certificate error usually means the A record was
created minutes ago and Caddy is still retrying; wait two minutes and run the curl again.
`FORCE_HTTPS=true` is what makes the application write `https://` into the pages Caddy serves,
and without it the setup form posts to `http://` and your browser blocks it.

The first screen at https://<DOMAIN> shows the heading `Setup LinkStack` and a language menu.
Open it now and work through the five screens. Three answers matter: choose SQLite when it asks
for a database type, use a password from your password manager on the admin screen, and on the
last screen set `Enable registration` to `No` unless you actually want a site other people sign
up to. Then come back here.

```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/register
docker compose exec -T linkstack sh -c 'test ! -f /htdocs/INSTALLING && echo "installer removed"'
curl -sSL https://<DOMAIN>/login | grep -c 'Sign In'
docker compose exec -T linkstack sh -c "sed -i 's/^APP_DEBUG=true$/APP_DEBUG=false/; s/^APP_ENV=local$/APP_ENV=production/' /htdocs/.env"
docker compose exec -T linkstack grep -E '^APP_(DEBUG|ENV)=' /htdocs/.env
```

You should see, in order: `404`, then `installer removed`, then a number greater than `0`, then
`APP_DEBUG=false` and `APP_ENV=production`.

If you do not: a `200` from `/register` means registration is still open, so sign in, open the
admin configuration page, set registration off and run the curl again. That `404` is the
security check in this step and it is worth insisting on. If `installer removed` does not
print, the wizard's last screen never submitted, so go back to https://<DOMAIN> and finish it,
because those setup routes answer without a login. The last two lines turn off the debug mode
the application ships with, so a PHP error now shows a generic page rather than a stack trace
with your file paths in it. A running container is not success; all four of these are.

## 8. First backup and restore

Two artifacts. The data archive holds what you made. The config archive holds the files that
rebuild the service around it.

```bash
cd /srv/linkstack
docker compose exec -T linkstack tar -czf - -C /htdocs .env database assets/img themes > /srv/linkstack/backups/linkstack-data-$(date +%F).tar.gz
sudo tar -czf /srv/linkstack/backups/linkstack-config-$(date +%F).tar.gz -C /srv/linkstack compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/linkstack/backups/
```

You should see: two files, both a few hundred kilobytes on a fresh install.

If you do not: a data archive of about 20 bytes means the `tar` inside the container failed and
the shell created the file anyway. Run the same line without the redirect to read the error.
The archive is small on purpose: it leaves out the application code, which comes back from the
pinned image. The `.env` inside it carries `APP_KEY`, and without that key the sessions and
password-reset links in a restored database stop verifying, so it is the one file in there that
cannot be replaced.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/linkstack
scp vps:/srv/linkstack/backups/* ~/backups/linkstack/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/linkstack/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:`
prefix only means something on your own machine, where the alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty page:

```bash
cd /srv/linkstack
docker compose down -v
docker compose up -d
sleep 30
docker compose exec -T linkstack tar -xzf - -C /htdocs < backups/linkstack-data-$(date +%F).tar.gz
docker compose exec -T linkstack rm -f /htdocs/INSTALLING
curl -sSL https://<DOMAIN>/login | grep -c 'Sign In'
```

You should see: `1` or more from the last line, and your own account still signs in.

If you do not: `down -v` is the one place `-v` belongs, because it drops the volume on purpose
so the image can refill /htdocs. If the site shows the setup wizard again, the `rm -f` did not
run: the fresh image brings the setup marker back with it, and removing that file is what turns
the installer off. Those four commands are the whole disaster plan, and the links, the theme
and the uploaded images are all in the one archive.

## 9. Updating later

Two things move here and they move separately, which is unusual enough to say out loud.

The application in /htdocs updates from inside. Upstream's documented path for this image is
the update notice in the admin panel and its one-click updater, which rewrites the files in the
volume. Take both archives from step 8 before you click it.

The runtime, meaning Alpine, Apache and PHP, updates by digest. New digests appear at
https://hub.docker.com/r/linkstackorg/linkstack/tags after a release at
https://github.com/LinkStackOrg/LinkStack/releases. Edit the image line in
/srv/linkstack/compose.yml to the new digest, then:

```bash
cd /srv/linkstack
docker compose pull
docker compose up -d
docker compose logs --tail 20 linkstack
```

You should see: the container starting once, and no repeating restart.

If you do not: put the old digest back and run the same three commands. This step leaves
/htdocs alone, because Docker only fills a volume that is empty, so a new digest gives you a
newer Apache and PHP and the same application. Re-run step 7's `curl` checks before you call
the update done.

## 10. What will probably go wrong

The minute between step 7 starting the container and you finishing the wizard is the one
genuinely dangerous minute in this install, and it does not look dangerous. I read the
installer's routes while writing this: while the setup marker file is present, every one of
them answers without a login, and one of them exists to skip the wizard by seeding an
administrator whose password is a fixed string published in the project's source. A hostname
that resolves publicly gets scanned within minutes of its first certificate. So do not start
the container and then go and make coffee. If you cannot sit down with the form right now, run
`docker compose stop` and begin step 7 again when you can.

## 11. Out of scope

- Do not choose MySQL in the setup wizard. SQLite inside the container is what makes this one
  container and one archive; MySQL means a second service this prompt does not install.
- Do not configure SMTP. LinkStack ships a built-in mail setting that relays through a service
  the project runs, and changing it is a decision about someone else's terms.
- Do not run the in-app updater or the theme updater during this install. Both rewrite files in
  the volume, and step 8 has not run yet.
- Do not publish the container's 443 or open 8121 in the firewall. Caddy is the only way in.

315 lines · 14,991 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install LinkStack 4.8.6 on this computer, answering at http://localhost:8121 and nowhere else,
with everything it keeps under ~/selfhost/linkstack/.

## 1. Preflight

Say this to the user first, because it decides whether they want the install at all. A
link-in-bio page is an address other people open. This one answers at http://localhost:8121,
which reads as "the computer I am typing on" to everyone, so a link sent to a friend or opened
on their own phone goes nowhere. What they get is the admin panel, the themes and their page as
it will look, on one machine.

Find out which operating system this is. Steps 2 and 8 branch on it.

```bash
uname -s
case "$(uname -s)" in
  Darwin) sysctl -n hw.memsize | awk '{printf "%d MB of RAM installed\n", $1/1048576}' ;;
  Linux) free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory" ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. LinkStack
needs 512 MB of RAM and 5 GB free on the home disk, and the image covers amd64, arm64 and two
32-bit arm variants. Free disk under 5 GB is a stop on any system. Only the Linux line measures
the RAM floor: under 512 MB available, print both numbers and stop. macOS and Windows print
installed RAM instead (bytes on Windows, divide by 1048576), which any machine running Docker
Desktop clears; there the floor is Docker Desktop's own allocation, under Settings, Resources.
Do not install and hope.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they confirm.
  Either way, then STOP: tell the user to open Docker Desktop once, accept its terms, and wait
  for the whale icon to say it is running. Do not continue until they confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and install
  it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may ask for a
  reboot; if it does, STOP and tell the user to reboot and come back, this prompt resumes at
  this step. Then STOP: have the user open Docker Desktop, accept its terms, and confirm it
  says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt repository,
  with its signing key saved to a file first, never piped into a shell. The fence is guarded, a
  no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the user
  in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose plugin
  with their distribution's package manager, and to run this prompt again once `docker info`
  works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not continue
without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/linkstack/backups
ls -la ~/selfhost/linkstack
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder and no
ownership fix on any system: the application, its themes, its uploads and its SQLite
database live in a Docker volume: the image ships the application at /htdocs, and Docker fills
an empty named volume but never a bind mount. Step 8 pulls those files into `backups`.

## 4. Secrets

Nothing is generated here and there is no `openssl rand` in this prompt. LinkStack's only
credential is the administrator account, created in a browser at step 7. This step writes
configuration: the name Apache answers to, which the compose file reads.

```bash
umask 077
cat > ~/selfhost/linkstack/.env <<'EOF'
HTTP_SERVER_NAME=localhost
HTTPS_SERVER_NAME=localhost
EOF
chmod 600 ~/selfhost/linkstack/.env
umask 022
cat ~/selfhost/linkstack/.env
```

Assert: `cat` prints exactly those two lines. Say two things. There are two files called `.env`
here: this one is Compose's, and the application keeps its own at /htdocs inside the container,
which steps 7 and 8 reach with `docker compose exec`. And on Windows, mode bits on NTFS are
advisory, so `chmod` protects nothing by itself; the real boundary is their own account, and
everything here is in their home directory.

## 5. compose.yml

```bash
cat > ~/selfhost/linkstack/compose.yml <<'EOF'
# LinkStack · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker setup ... https://docs.linkstack.org/docker/setup/
#   image build .... https://github.com/LinkStackOrg/linkstack-docker/blob/main/Dockerfile
#
# One container: Alpine, Apache 2 and PHP 8.3, carrying LinkStack 4.8.6 and the
# SQLite file it keeps links and users in. It sits in ~/selfhost/linkstack/ and
# reads ./.env from beside itself, which lets one file work on all three OSes.
#
# /htdocs is a named volume, not a relative bind mount: the image ships the
# application there, and Docker fills an empty named volume from the image but
# never a bind mount.
#
# The pin is a digest with no tag, because upstream publishes no version tags.
# Manifest list read from Docker Hub on 2026-08-06; /htdocs/version.json inside
# it reads 4.8.6. It covers amd64, arm64 and two 32-bit arm variants.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  linkstack:
    image: linkstackorg/linkstack@sha256:1c8b05399ee459ac601bac3eede7fbe765d1b6b7be725663b57f3220610958bf
    container_name: linkstack
    restart: unless-stopped
    # HTTP_SERVER_NAME and HTTPS_SERVER_NAME are Apache's ServerName, and on
    # this path both are localhost. They live in .env beside this file.
    env_file: ./.env
    environment:
      TZ: UTC
      # Apache's log level. The image default is info; warn keeps the container
      # log to lines worth reading.
      LOG_LEVEL: warn
      PHP_MEMORY_LIMIT: 256M
      UPLOAD_MAX_FILESIZE: 8M
    volumes:
      - linkstack-htdocs:/htdocs
    ports:
      # Loopback only: no other device on this network reaches 8121, not the
      # user's own phone. The container also listens on 443 with a self-signed
      # certificate, which this file never publishes. The image's own health
      # check curls http://localhost, this same port.
      - "127.0.0.1:8121:80"

volumes:
  linkstack-htdocs:
EOF
cd ~/selfhost/linkstack && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one port, one named volume.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision, not an omission:

- The published port is `127.0.0.1:8121`, this computer only. Another laptop on the same wifi
  cannot reach it, and neither can the user's phone. That is this path, not a defect.
- No hostname, so nothing to resolve and nothing to certify. Browsers treat `http://localhost`
  as a secure context, so pages needing crypto still work.
- No firewall rule, because nothing is published past loopback.
- The application's `FORCE_HTTPS` setting stays off, unlike the server path: nothing terminates
  TLS here, and forcing the scheme would print addresses that do not answer.

```bash
grep -n '127.0.0.1' ~/selfhost/linkstack/compose.yml
```

Assert: one line, `- "127.0.0.1:8121:80"`.

## 7. Start and verify

```bash
cd ~/selfhost/linkstack
docker compose pull
docker compose up -d
for i in $(seq 1 30); do state=$(docker inspect --format '{{.State.Health.Status}}' linkstack 2>/dev/null || echo starting); echo "$i $state"; [ "$state" = healthy ] && break; sleep 5; done
curl -sSL -o /dev/null -w '%{http_code}\n' http://localhost:8121/
curl -sSL http://localhost:8121/ | grep -c 'Setup LinkStack'
```

Assert all three, and print what you received: the loop ends on `healthy`; the curl prints
`200`; the last prints a number greater than `0`. If any misses, stop, run
`docker compose logs --tail 40 linkstack`, and name the likely cause. If `port is already
allocated` came back, find what holds 8121 (`lsof -nP -iTCP:8121 -sTCP:LISTEN`,
`ss -ltnp | grep 8121` on Linux, `netstat -ano | findstr :8121` on Windows) and stop until the
user frees it. A running container is not success.

The first screen at http://localhost:8121 shows the heading `Setup LinkStack` and a language
menu.

STOP: tell the user to open http://localhost:8121 now and work through the five setup screens,
and wait. Do not continue until they confirm. Tell them the three answers that matter: choose
SQLite for the database type, use a password from their password manager on the admin screen,
and set `Enable registration` to `No` on the last screen.

Once they confirm, prove the install closed behind them:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8121/register
docker compose exec -T linkstack sh -c 'test ! -f /htdocs/INSTALLING && echo "installer removed"'
curl -sSL http://localhost:8121/login | grep -c 'Sign In'
docker compose exec -T linkstack sh -c "sed -i 's/^APP_DEBUG=true$/APP_DEBUG=false/; s/^APP_ENV=local$/APP_ENV=production/' /htdocs/.env"
docker compose exec -T linkstack grep -E '^APP_(DEBUG|ENV)=' /htdocs/.env
```

Assert, all four: `/register` prints `404`, the application's answer once registration is off;
`installer removed` prints, so the setup routes are gone; the login count is above `0`; and the
last grep prints `APP_DEBUG=false` and `APP_ENV=production`, so a PHP error shows a generic
page, not a stack trace. All four before you report success.

## 8. First backup and restore

Two artifacts: the data archive holds what the user made, the config archive the files that
rebuild the service around it.

```bash
cd ~/selfhost/linkstack
docker compose exec -T linkstack tar -czf - -C /htdocs .env database assets/img themes > backups/linkstack-data-$(date +%F).tar.gz
tar -C ~/selfhost/linkstack -czf backups/linkstack-config-$(date +%F).tar.gz compose.yml .env
ls -lh backups/
```

Assert: both files exist and both are non-empty. Print both sizes. The data archive is small
because it leaves out the application code, which comes back from the pinned image. The `.env`
inside carries `APP_KEY`; without it the sessions and password-reset links in a restored
database stop verifying, so it is the one file that cannot be replaced.

Both archives sit on the same disk as the data, which is not a backup, and on one computer the
disk and the machine fail together. Ask which folder a sync service already watches, or have
them plug in a USB stick: /Volumes on macOS, usually /media on Linux, a drive letter such as /d
in Git Bash. Confirm it with `ls -d`, then `cp` both archives there. Do not guess a path.
Assert: the user confirms both filenames are listed there.

To restore, run `ls -lh backups/`, have the user name the data archive, and put that filename in
the `ARCHIVE` slot. `down -v` drops the volume on purpose, `up -d` refills /htdocs from the
pinned image, and the `rm -f` clears the setup marker the fresh image brings back:

```bash
cd ~/selfhost/linkstack
docker compose down -v && docker compose up -d && sleep 30
docker compose exec -T linkstack tar -xzf - -C /htdocs < backups/ARCHIVE
docker compose exec -T linkstack rm -f /htdocs/INSTALLING
```

Then open http://localhost:8121 and sign in. That is the disaster plan.

## 9. Updating later

Two things move here, separately. The application in /htdocs updates from inside: upstream's
documented path for this image is the update notice in the admin panel and its one-click
updater, which rewrites the volume. Take both archives from step 8 first.

The runtime, meaning Alpine, Apache and PHP, updates by digest. New digests appear at
https://hub.docker.com/r/linkstackorg/linkstack/tags after a release at
https://github.com/LinkStackOrg/LinkStack/releases. Edit the image line in
~/selfhost/linkstack/compose.yml to the new digest, then:

```bash
cd ~/selfhost/linkstack
docker compose pull
docker compose up -d
docker compose logs --tail 20 linkstack
```

That replaces the container and leaves /htdocs alone, because Docker only fills an empty
volume. Re-run step 7's first three commands before calling the update done.

## 10. What will probably go wrong

Docker Desktop, after a reboot. The compose file says `restart: unless-stopped` and I read that
as a promise the container comes back by itself. It does not on macOS or Windows: nothing
returns until Docker Desktop is running, and it starts at sign-in, sometimes only when someone
opens it. I rebooted, opened my own page to show somebody, and got a browser error that reads
exactly like a broken install rather than a stopped daemon. Tell the user to turn on Docker
Desktop's start-at-login, and to run `cd ~/selfhost/linkstack && docker compose up -d` after a
reboot before concluding anything is wrong.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not choose MySQL in the setup wizard. SQLite inside the container is what makes this one
  container and one archive; MySQL adds a service this prompt does not install.
- Do not run the in-app updater or the theme updater during this install. Both rewrite files in
  the volume, and step 8 has not run yet.
- Do not rebind 8121 to this machine's network address so a phone can open the page. That puts
  a sign-in form on every network this computer joins.
compose.local.ymlthe services, pinned · local layout45 lines

authored from upstream docs, never pasted · 2,020 bytes

# LinkStack · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker setup ... https://docs.linkstack.org/docker/setup/
#   image build .... https://github.com/LinkStackOrg/linkstack-docker/blob/main/Dockerfile
#
# One container: Alpine, Apache 2 and PHP 8.3, carrying LinkStack 4.8.6 and the
# SQLite file it keeps links and users in. It sits in ~/selfhost/linkstack/ and
# reads ./.env from beside itself, which lets one file work on all three OSes.
#
# /htdocs is a named volume, not a relative bind mount: the image ships the
# application there, and Docker fills an empty named volume from the image but
# never a bind mount.
#
# The pin is a digest with no tag, because upstream publishes no version tags.
# Manifest list read from Docker Hub on 2026-08-06; /htdocs/version.json inside
# it reads 4.8.6. It covers amd64, arm64 and two 32-bit arm variants.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  linkstack:
    image: linkstackorg/linkstack@sha256:1c8b05399ee459ac601bac3eede7fbe765d1b6b7be725663b57f3220610958bf
    container_name: linkstack
    restart: unless-stopped
    # HTTP_SERVER_NAME and HTTPS_SERVER_NAME are Apache's ServerName, and on
    # this path both are localhost. They live in .env beside this file.
    env_file: ./.env
    environment:
      TZ: UTC
      # Apache's log level. The image default is info; warn keeps the container
      # log to lines worth reading.
      LOG_LEVEL: warn
      PHP_MEMORY_LIMIT: 256M
      UPLOAD_MAX_FILESIZE: 8M
    volumes:
      - linkstack-htdocs:/htdocs
    ports:
      # Loopback only: no other device on this network reaches 8121, not the
      # user's own phone. The container also listens on 443 with a self-signed
      # certificate, which this file never publishes. The image's own health
      # check curls http://localhost, this same port.
      - "127.0.0.1:8121:80"

volumes:
  linkstack-htdocs:

agent-readable mirror: /self-host/linktree.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned52 lines

authored from upstream docs, never pasted · 2,610 bytes

# LinkStack · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker setup ....... https://docs.linkstack.org/docker/setup/
#   reverse proxies .... https://docs.linkstack.org/docker/reverse-proxies/
#   image build ........ https://github.com/LinkStackOrg/linkstack-docker/blob/main/Dockerfile
#   app configuration .. https://github.com/LinkStackOrg/LinkStack/blob/v4.8.6/.env
#
# One container: Alpine, Apache 2 and PHP 8.3, carrying LinkStack 4.8.6 and the
# SQLite file it keeps links and users in. There is no database service here.
#
# /htdocs is a named volume and not a bind mount, and that is not a style
# choice. The image ships the application at /htdocs, and Docker copies image
# content into an empty named volume only, never into a bind mount, so a bind
# mount would start the container on an empty document root. Upstream documents
# that path as downloading the release yourself and giving the files uid 100
# and gid 101, which this install does not do.
#
# The pin is a digest with no tag, because upstream publishes no version tags:
# linkstackorg/linkstack carries a rolling tag plus four architecture tags from
# 2023. This is the manifest list digest read from Docker Hub on 2026-08-06.
# /htdocs/version.json inside it reads 4.8.6, and the list covers linux/amd64,
# linux/arm/v6, linux/arm/v7 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  linkstack:
    image: linkstackorg/linkstack@sha256:1c8b05399ee459ac601bac3eede7fbe765d1b6b7be725663b57f3220610958bf
    container_name: linkstack
    restart: unless-stopped
    # HTTP_SERVER_NAME and HTTPS_SERVER_NAME are Apache's ServerName. They carry
    # the hostname, so they live in .env beside this file rather than in it.
    env_file: /srv/linkstack/.env
    environment:
      TZ: UTC
      # Apache's log level. The image default is info, which narrates every
      # start-up; warn keeps the container log to lines worth reading.
      LOG_LEVEL: warn
      PHP_MEMORY_LIMIT: 256M
      UPLOAD_MAX_FILESIZE: 8M
    volumes:
      - linkstack-htdocs:/htdocs
    ports:
      # Loopback only: the host's Caddy is the one thing that reaches 8121, and
      # it reaches the container's plain HTTP port. The container also listens
      # on 443 with a self-signed certificate, and this file never publishes it.
      # The image's own health check is curl against http://localhost, so the
      # HTTP port is the one upstream tests too.
      - "127.0.0.1:8121:80"

volumes:
  linkstack-htdocs:
Caddyfilethe hostname and TLS33 lines

authored from upstream docs, never pasted · 1,318 bytes

# LinkStack · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.linkstack.org/docker/reverse-proxies/,
# https://docs.linkstack.org/docker/setup/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also HTTP_SERVER_NAME in .env, and it is the address printed on the profiles
# and QR codes people scan, so it is the value here worth choosing once.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8121 is the loopback port compose publishes, and it maps to the
	# container's plain HTTP port. Upstream's own example proxies to the
	# container's 443 with certificate checking switched off; this block does
	# not, because that certificate is self-signed and skipping the check on
	# a hop is worse than not making the hop over TLS at all. The scheme the
	# application prints in its own pages comes from FORCE_HTTPS in the app's
	# .env, which step 7 turns on, so nothing here needs the back end to
	# speak TLS.
	reverse_proxy 127.0.0.1:8121
}
install.shthe same install, no agent189 lines

authored from upstream docs, never pasted · 8,734 bytes

#!/usr/bin/env bash
# LinkStack · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=links.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.linkstack.org/docker/setup/
#   https://docs.linkstack.org/docker/reverse-proxies/
#   https://github.com/LinkStackOrg/linkstack-docker/blob/main/Dockerfile
#   https://github.com/LinkStackOrg/LinkStack/blob/v4.8.6/.env
#   https://caddyserver.com/docs/automatic-https
#
# Nothing is generated here. LinkStack's only credential is the administrator
# account, and you create it in a browser at step 6. The .env this script writes
# is configuration: the hostname Apache answers to.
#
# DOMAIN_HOST is the address on every profile link and QR code you hand out.
# Choose it once; moving it later means reprinting whatever the code is on.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/linkstack}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. links.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 512 ] || die "only ${avail_mb} MB of RAM available; Apache plus PHP wants 512 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# Two files under /srv. The application, its themes, its uploads and its SQLite
# database live in a Docker volume: the image ships the application at /htdocs,
# and Docker fills an empty named volume from the image but never a bind mount.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		HTTP_SERVER_NAME=${DOMAIN_HOST}
		HTTPS_SERVER_NAME=${DOMAIN_HOST}
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 3. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-linkstack"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 4. Ports: two open, and 8121 is not one of them --------------------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8121 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 5. Start it and prove it is serving --------------------------------------
#
# FORCE_HTTPS lives in the application's own .env inside the volume, so it can
# only be set once the container has filled that volume from the image. It makes
# the application write https:// into the pages Caddy serves; without it the
# setup form posts to http:// and a browser blocks the request.

docker compose pull
docker compose up -d

echo "==> waiting for the container's own health check to go green"
for _ in $(seq 1 48); do
	state="$(docker inspect --format '{{.State.Health.Status}}' linkstack 2>/dev/null || echo starting)"
	[ "$state" = "healthy" ] && break
	sleep 5
done
[ "${state:-}" = "healthy" ] || die "container health is ${state:-unknown}. Check: docker compose logs --tail 40 linkstack"

docker compose exec -T linkstack sed -i 's/^FORCE_HTTPS=false$/FORCE_HTTPS=true/' /htdocs/.env
docker compose exec -T linkstack grep -q '^FORCE_HTTPS=true$' /htdocs/.env \
	|| die "FORCE_HTTPS did not take in /htdocs/.env. Check: docker compose exec -T linkstack cat /htdocs/.env"

echo "==> waiting for https://${DOMAIN_HOST}/ (Caddy is getting a certificate)"
for _ in $(seq 1 30); do
	code="$(curl -sSL -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
	[ "$code" = "200" ] && break
	sleep 5
done
[ "${code:-}" = "200" ] || die "https://${DOMAIN_HOST}/ answered ${code:-nothing}. Check: sudo journalctl -u caddy -n 30"

curl -sSL "https://${DOMAIN_HOST}/" | grep -q 'Setup LinkStack' \
	|| die "the page answered 200 without the setup heading. Check: docker compose logs --tail 40 linkstack"

# --- 6. Create the administrator account, in a browser ------------------------
#
# The wizard is the only way in. While the INSTALLING marker is present its
# routes answer without a login, including one that seeds a default
# administrator, so this window is short on purpose.

cat <<-SETUP

	Open https://${DOMAIN_HOST} now and work through the five setup screens.
	Three answers matter:

	  - choose SQLite when it asks for a database type
	  - use a password from your password manager on the admin screen
	  - set "Enable registration" to No on the last screen

	Do it now rather than later: until that wizard is finished, anyone who
	loads the page can finish it instead of you.

SETUP
printf 'Press Return once the wizard has finished and you are looking at the dashboard. '
read -r _

# --- 7. Prove the install closed behind you -----------------------------------

reg="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/register" || true)"
[ "$reg" = "404" ] || die "https://${DOMAIN_HOST}/register returned ${reg}, not 404. Registration is still open; turn it off in the admin panel."

docker compose exec -T linkstack sh -c 'test ! -f /htdocs/INSTALLING' \
	|| die "the setup marker is still present, so the installer routes are still live. Finish the wizard's last screen."

curl -sSL "https://${DOMAIN_HOST}/login" | grep -q 'Sign In' \
	|| die "the sign-in page did not render. Check: docker compose logs --tail 40 linkstack"

docker compose exec -T linkstack sh -c "sed -i 's/^APP_DEBUG=true\$/APP_DEBUG=false/; s/^APP_ENV=local\$/APP_ENV=production/' /htdocs/.env"
docker compose exec -T linkstack grep -q '^APP_DEBUG=false$' /htdocs/.env \
	|| die "APP_DEBUG is still true, so PHP errors would show a stack trace to visitors"

# --- 8. The first backup, before day one ends ---------------------------------
#
# Two artifacts: what you made, and the files that rebuild the service around it.
# The data archive leaves out the application code, which comes back from the
# pinned image. Its .env carries APP_KEY, which is the one value in here that
# cannot be replaced.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T linkstack tar -czf - -C /htdocs .env database assets/img themes > "$APP_DIR/backups/linkstack-data-${STAMP}.tar.gz"
sudo tar -czf "$APP_DIR/backups/linkstack-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/linkstack-data-${STAMP}.tar.gz" ] || die "the data archive is empty"
[ -s "$APP_DIR/backups/linkstack-config-${STAMP}.tar.gz" ] || die "the config archive is empty"

cat <<-DONE

	LinkStack is answering at https://${DOMAIN_HOST}/

	  1. Registration is off and the setup routes are gone. Both were checked,
	     not assumed: /register returns 404 and the INSTALLING marker is gone.
	  2. Your page lives at https://${DOMAIN_HOST}/@ plus the handle you chose,
	     and the admin panel is at https://${DOMAIN_HOST}/dashboard
	  3. Updating is two separate jobs. The application updates from inside,
	     through the admin panel's updater, which rewrites the volume. The
	     runtime updates by editing the image digest in $APP_DIR/compose.yml.
	     Back up before either.
	  4. First backup written to $APP_DIR/backups: a data archive and a config
	     archive. They are on the same disk as the data, which is not a backup.
	     Copy them somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Linktree.

  • The domain is the product. A link-in-bio address gets printed on packaging, read out on stage and typed into phones by people who will never see this server, and keeping it answering is now your registration to renew and your certificate to keep valid. That promise is most of what Linktree actually sells.
  • The application updates itself from inside. Upstream's documented update path for this image is the one-click updater in the admin panel, which rewrites the application files in the Docker volume, so the pinned digest describes what you installed on day one rather than what is running in month six.
  • Mail leaves through somebody else's relay unless you change it. LinkStack ships with a built-in mail setting pointed at a free service the project runs under its own terms, and that is the path a password-reset message would take.
  • Click counts, not marketing analytics. You get visits and per-link clicks in the admin panel. There are no funnels, no audience segments, no scheduled social posts and no mailing-list integrations.
  • No selling. Linktree's upper tiers are mostly commerce: digital products, an affiliate shop, checkout, and the seller fee that comes with each. None of that exists here, and adding it is a different product rather than a setting.

Where this came from

“It's also possible to run the image with Docker Bind Mounts instead of Docker Volumes. But you have to download the latest release of linkstack by yourself and place it in the mounted directory if you do that.”

  • The official image exposes HTTP on port 80 and HTTPS on port 443, keeps everything at /htdocs, takes its Apache server name from HTTP_SERVER_NAME, and publishes amd64, arm64 and two 32-bit arm variants. source
  • Upstream's reverse-proxy page tells you to set FORCE_HTTPS to true in the persistent .env once the container sits behind a proxy. source
  • FORCE_HTTPS makes the application generate https URLs and send a Content-Security-Policy of upgrade-insecure-requests; the separate FORCE_ROUTE_HTTPS setting is the one that redirects. source
  • With ALLOW_REGISTRATION off, the /register route returns 404 rather than a sign-up form, which is what makes closed registration checkable from a shell. source
  • While the INSTALLING marker file is present the setup routes answer without a login, including one that seeds a default administrator account and signs the caller in. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Linktree?

    Not Linktree itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is LinkStack. Your own link-in-bio page, with an admin panel, themes and a QR code, on a domain you keep. The install is one command: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 10 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Linktree?

    LinkStack. Your own link-in-bio page, with an admin panel, themes and a QR code, on a domain you keep. The only one of these that gives you the part people actually pay for beyond the links: an admin panel where you add a link, pick a theme and get a QR code without editing a file or redeploying anything. It is one container with SQLite inside it, so the install is a compose file and a five-screen setup wizard. What it does not replace is Linktree's commerce: no checkout, no affiliate shop, no seller payouts, and per-link click counts rather than audience analytics. LinkStack is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Linktree?

    512 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. LinkStack itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Linktree Pro, $15/mo — $180 a year.

  • How hard is it really?

    ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the LinkStack install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run LinkStack on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same LinkStack on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: The page answers at http://localhost:8121, which resolves on this computer and nowhere else, so what you get is your profile exactly as it would look rather than an address anyone can open. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.