SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Self-host LinkSift, a yt-dlp web UI

LinkSift has no login and binds to localhost on purpose. Run it in Docker Compose, then put it behind Caddy with real auth or on a Tailscale node.

What LinkSift is, and why it binds to localhost

LinkSift is a self-hosted web interface for yt-dlp, and it ships with no login. Its README states the rule directly: "Do not expose it directly to the internet or an untrusted LAN." The project's SECURITY.md repeats it: "It has no built-in authentication and must not be directly exposed to untrusted networks." That is why the published compose file maps the port as 127.0.0.1:8899:8899 and not 8899:8899.

yt-dlp is a general purpose command line downloader. You hand it a URL, it works out what media that page serves, and it writes the file. LinkSift wraps that in a browser page with a job queue. The extractors underneath are unchanged, and the question of what you are permitted to fetch stays between you and the site you fetch from.

The risk of an open instance is mechanical, so it is worth stating once. Anyone who loads the page can submit a URL, and your server then fetches that URL and writes the result to your disk under your provider's IP address. There is no account to suspend and no quota you did not set yourself. The install is one command. Everything after it is the part that matters: getting that page to your browser without putting it on the public internet.

What is inside the image

Read the Dockerfile before you deploy, because it tells you exactly what you are running.

  • python:3.12-slim as the base, with the Flask app served by gunicorn
  • ffmpeg installed from apt
  • Deno, copied in as a static binary from denoland/deno:bin-2.4.3
  • a non-root user named linksift with UID 1000, owning /app
  • gunicorn launched as -b 0.0.0.0:8899 -w 1 --threads 4 --timeout 600

That last line decides how you proxy it. Gunicorn binds 0.0.0.0 inside the container, which is why publishing the port works at all. The HOST variable documented in the README defaults to 127.0.0.1, and it applies when you run the app directly through linksift.sh. It does not apply to the container, because the container's command names the bind address explicitly. So the container is not kept private by its own bind address. The 127.0.0.1: prefix on the host side of the port mapping is doing that job.

ffmpeg is a real dependency and not an optional extra. Most sites serve video and audio as separate streams once you go above the lowest quality, so yt-dlp downloads both and calls ffmpeg to merge them into one file. Without ffmpeg you are limited to whichever single stream already carries both tracks.

Deno is a real dependency too, for a reason that is recent. YouTube serves a JavaScript challenge that has to be executed before a usable media URL exists. yt-dlp solves it by running the yt-dlp-ejs solver scripts inside an external JavaScript runtime, and upstream picked Deno because it is a single portable executable that denies filesystem and network access by default. The image carries that binary already, so this works with no setup from you. It also means a hand-built yt-dlp box with no JavaScript runtime installed will fail on YouTube where LinkSift succeeds.

One worker with four threads is a constraint you will meet. Four long downloads occupy every thread, so the page stops answering until one of them finishes. That is why LINKSIFT_MAX_CONCURRENT_DOWNLOADS exists, defaulting to 3, and why 2 is a sensible value on a small VPS.

The compose file to deploy

Start from the upstream compose.ghcr.yml and change two things: pin the version, and lower the concurrency.

name: linksift

services:
  linksift:
    image: ghcr.io/loveisbl1nd/linksift:0.2.0
    container_name: linksift
    ports:
      - "127.0.0.1:8899:8899"
    volumes:
      - linksift-downloads:/app/downloads
    environment:
      LINKSIFT_MAX_CONCURRENT_DOWNLOADS: "2"
      LINKSIFT_MAX_QUEUED_DOWNLOADS: "50"
    restart: unless-stopped

volumes:
  linksift-downloads:

Bring it up and check it before you build anything on top.

docker compose up -d
docker compose logs linksift
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8899/

The log should end with gunicorn naming its bind address:

[INFO] Listening at: http://0.0.0.0:8899 (1)

The curl line prints an HTTP status code from a running instance. If it prints 000, nothing is listening, and curl will have reported Failed to connect to 127.0.0.1 port 8899: Connection refused. Check docker compose ps for a container stuck in a restarting state, then read the full log.

Downloads live in a named volume instead of a folder in your home directory. That is deliberate. The container runs as UID 1000, so a bind mount owned by a different user gives you Permission denied on the first write with nothing else to explain it. A fresh named volume inherits ownership from the image path, which the Dockerfile already chowns to linksift, so it works with no extra step. Use a bind mount when the files must land somewhere specific on the host, and set the ownership yourself when you do. The difference between bind mounts and named volumes is worth reading before you choose, because moving the data later costs more than choosing correctly now.

Reaching the UI before you have a proxy

The port is on loopback, so your laptop cannot reach it yet. Do not fix that by deleting the 127.0.0.1: prefix. Forward the port over SSH instead.

ssh -N -L 8899:127.0.0.1:8899 you@your-vps

Leave that running and open http://127.0.0.1:8899 in your browser. The traffic rides your existing SSH session, so it is encrypted and authenticated by your SSH key, and nothing new listens on the public interface. For occasional use this is the whole answer, and you can stop here.

Why the loopback prefix beats a firewall rule

A common mistake is to publish the port widely and then block it with ufw (uncomplicated firewall). It does not work, and the reason applies to every container you will ever run.

When Docker publishes a port, it destination-NATs (network address translation) the packet to the container and the kernel then handles it on the forward path. ufw writes its rules into the input path, which that packet never reaches. So sudo ufw deny 8899 is never consulted, the packet arrives at the container, and the port is open to the internet while your firewall reports it closed.

Check what is actually listening:

sudo ss -lntp | grep 8899

A safe deployment shows the loopback address:

LISTEN 0  4096  127.0.0.1:8899  0.0.0.0:*  users:(("docker-proxy",pid=1234,fd=4))

Seeing 0.0.0.0:8899 there means the port is reachable from anywhere that can route to your VPS. Fix it in the compose file, not in the firewall.

Option 1: a reverse proxy with TLS and a password

For access from a browser on a network you do not control, put a reverse proxy in front and give it the two things LinkSift lacks: a certificate and an authentication step. Caddy is the shortest path, because it requests and renews certificates over ACME (automatic certificate management environment) with no extra configuration.

Point a DNS A record at the VPS first, and open 80 and 443. Port 80 has to stay open because the ACME HTTP challenge arrives there.

Now drop the published port from LinkSift entirely and put both containers on one network.

name: linksift

services:
  linksift:
    image: ghcr.io/loveisbl1nd/linksift:0.2.0
    container_name: linksift
    expose:
      - "8899"
    volumes:
      - linksift-downloads:/app/downloads
    environment:
      LINKSIFT_MAX_CONCURRENT_DOWNLOADS: "2"
    restart: unless-stopped
    networks:
      - edge

  caddy:
    image: caddy:2.11
    container_name: caddy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    restart: unless-stopped
    networks:
      - edge

networks:
  edge:

volumes:
  linksift-downloads:
  caddy-data:
  caddy-config:

The LinkSift service has no ports: entry now, so no host port exists and there is nothing on the VPS to scan. Caddy still reaches it as linksift:8899, because containers on a user-defined Docker network resolve each other by service name through Docker's embedded DNS resolver at 127.0.0.11. The caddy-data volume holds the issued certificates, so losing it means Caddy asks the certificate authority for everything again on the next start.

Generate a password hash with the same image you are about to run:

docker run --rm caddy:2.11 caddy hash-password --plaintext 'a long random password'

Put the output in a Caddyfile next to the compose file:

linksift.example.com {
    basic_auth {
        you $2a$14$REPLACE.WITH.THE.HASH.YOU.GENERATED
    }
    reverse_proxy linksift:8899
}

The directive is basic_auth with an underscore. It was named basicauth before Caddy 2.8, so an older example copied from somewhere else gives you an unrecognized directive: basicauth error and a container that will not start.

docker compose up -d
docker compose logs caddy
curl -sI https://linksift.example.com/ | head -1

A correct setup answers HTTP/2 401, because you sent no credentials. That 401 is the proof the proxy is doing its job. A 200 with no password prompt means the basic_auth block is not inside the site block, so check the braces and the indentation. If the certificate never arrives, the Caddy log names the reason, and Timeout during connect (likely firewall problem) in the challenge error means the certificate authority could not reach port 80. Check the VPS firewall and your provider's separate network firewall, which is its own control on most panels.

Basic authentication is a password on the door and nothing more. The browser resends the credentials on every request and there is no logout, which is fine for a single-user tool behind TLS (transport layer security). Put an identity-aware proxy in front instead once several people need access. Which proxy you pick matters less than having one, and the comparison of nginx, Caddy and Traefik covers the trade-offs if Caddy is not already what your other services use.

Option 2: a Tailscale node, so it is never public

If only your own devices need LinkSift, skip the public internet. Tailscale builds a private network between machines you have authorised, using WireGuard underneath, and gives each machine a stable name.

Install it on the VPS and sign in:

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up

Keep the first compose file, the one that publishes 127.0.0.1:8899:8899. Then hand that local port to Tailscale:

sudo tailscale serve --bg --https=443 localhost:8899
tailscale serve status

LinkSift is now served at https://<machine-name>.<your-tailnet>.ts.net/ with a valid certificate, reachable only from devices signed in to your tailnet. Turn on MagicDNS and HTTPS certificates in the tailnet's DNS settings before you run this, because tailscale serve --https cannot get a certificate without them.

Two things to know. tailscale serve keeps traffic inside the tailnet, while tailscale funnel is the sibling command that publishes the same service to the whole internet, which is exactly what you must not do here. Read the command twice before you run it. Second, the tailnet is the authentication boundary: LinkSift still has no login, so every device on the tailnet can use it. Tailscale ACLs narrow that down when you share the tailnet with other people.

Undo it with the same flags plus off:

sudo tailscale serve --https=443 off

Upgrades are routine maintenance, not an afterthought

A downloader breaks from the outside. A site changes its player and the extractor that read it yesterday returns ERROR: [youtube] <id>: Requested format is not available, or finds no media at all. Nothing on your server changed. This is normal for this category of software, which is why yt-dlp releases so often.

LinkSift absorbs part of that for you. The container's entrypoint updates yt-dlp at startup, so docker compose restart linksift picks up a current yt-dlp without pulling a new image. Set LINKSIFT_NO_UPDATE when you want a byte-identical container on every boot, and accept that a broken extractor then stays broken until you rebuild.

Pin the image tag regardless. ghcr.io/loveisbl1nd/linksift:0.2.0 is a real published tag, alongside 0.2, 0.1.0 and latest, built for linux/amd64 and linux/arm64 (checked August 2026). latest moves under you, so a docker compose pull on a Tuesday can hand you a different application than the one you tested, with no record of what changed. A pinned tag makes the upgrade a deliberate edit and the rollback a one-line revert.

Record the digest you are running so you can identify it later:

docker images --digests ghcr.io/loveisbl1nd/linksift

The upgrade is then: read the changelog, edit the tag, docker compose pull, docker compose up -d. The named volume is not tied to the container, so your downloads survive. If the new version misbehaves, put the old tag back and run the same two commands.

YouTube deserves its own note. It requires a proof of origin (PO) token from its player clients, and the base image covers the JavaScript side already with Deno and the yt-dlp-ejs solver scripts. The repository also ships docker-compose.youtube-robust.yml and a matching build target that installs a GPL licensed PO token provider plugin, pointed at a sidecar through LINKSIFT_PO_TOKEN_PROVIDER_URL. The Dockerfile records why that plugin is excluded from the startup updater: its version has to stay in lockstep with the sidecar image. Move to that variant when the base image starts failing on YouTube, and not before.

Where this fits next to a media stack

LinkSift is a single-purpose queue. It does not rename files and it does not serve them to a player. Point its volume at storage your other services already read, and let those services do their own job. The arr stack pattern in Docker Compose is the neighbouring approach for automated, ongoing acquisition, and the two never need to know about each other. For playback, running Jellyfin as a media server on a VPS reads whatever lands in the folder, and a self-hosted music streaming server does the same for audio. Keep the downloader private and let the players be the things with accounts.

One last item: disk fills quietly. LINKSIFT_JOB_TTL defaults to 86400 seconds and governs job records rather than the files on disk, so nothing is deleted for you. Run docker system df -v from time to time and clear out what you no longer want, because a full disk on a VPS stops far more than the downloader.

FAQ

Is it safe to expose LinkSift on the public internet?

No. It has no built-in authentication, so anyone who finds the page can queue downloads that your server performs and stores under your IP address. The README says not to expose it directly to the internet or an untrusted LAN. Publish the port as 127.0.0.1:8899:8899, then reach it through an SSH tunnel for occasional use, or put a reverse proxy with TLS and a password in front of it for regular use. A Tailscale node is the option that keeps it off the public internet entirely.

Why does ufw not block LinkSift's port?

Because a published Docker port is destination-NATed to the container and then handled on the forward path, while ufw writes its rules into the input path that the packet never reaches. The ufw deny 8899 rule is never consulted. Control the exposure in the compose file by binding the host side of the mapping to 127.0.0.1, then confirm it with sudo ss -lntp | grep 8899. The listener must show 127.0.0.1:8899 and not 0.0.0.0:8899.

Do I need to install Deno and ffmpeg myself?

Not when you use the Docker image, because both are already in it. The image installs ffmpeg from apt and copies the Deno binary out of denoland/deno:bin-2.4.3. ffmpeg merges the separate video and audio streams that sites serve at higher qualities. Deno executes the yt-dlp-ejs solver scripts that answer YouTube's JavaScript challenge, which yt-dlp must do before it can build a working media URL. Running LinkSift outside Docker means installing both yourself.

Which image tag should I run, and how do I upgrade?

Pin a version tag such as ghcr.io/loveisbl1nd/linksift:0.2.0 rather than latest, so a docker compose pull cannot swap the application under you without a record. To upgrade, change the tag in the compose file, then run docker compose pull followed by docker compose up -d. Downloads sit in a named volume that is not tied to the container, so they survive the replacement. Rolling back is those same two commands with the previous tag.

Why did a download that worked last month suddenly fail?

The site changed, not your server. Extractors break when a site alters its player or its URL format, and yt-dlp ships a fix in a later release. Restart the container first, because LinkSift's entrypoint updates yt-dlp at startup, so docker compose restart linksift often clears it with no new image. If you set LINKSIFT_NO_UPDATE, that path is switched off and you need a newer image instead.