Optional module
Public links for your clips
GoodBit works entirely on your own machine and never uploads anything. The publisher is the one exception, and only if you want it: a small server you run, which takes a clip you chose to publish and gives back a link that plays in a browser and embeds in Discord. No account, no third party, nothing leaves your control.
It is the most hands-on thing here, because a link anyone can open means a machine the internet can reach. That is a domain name, a reverse proxy and a forwarded port. None of it is hard; there is just a list.
Quick start
Node and Express in a container. It stores uploads on a volume, serves them at
/media/…, and renders an Open Graph embed page at /<filename>
so a link unfurls in Discord.
Run it
The image is on Docker Hub, built for amd64 and arm64, so there is nothing to clone and
nothing to compile. Save this as docker-compose.yml and run
docker compose up -d.
Or build it yourself
Same thing, from source, if you would rather not pull a stranger's container or you are on a processor the image does not cover.
git clone https://github.com/DarrellVS/goodbit.git
cd goodbit/publisher
docker build -t goodbit-publisher .
Then change image: in the compose file to goodbit-publisher.
Environment
| Variable | What it is |
|---|---|
PORT |
Port inside the container. The image defaults to 5555. |
UPLOAD_DIR |
Where clips are written. Defaults to /data/public, inside the
declared /data volume. Mount /data somewhere with room,
rather than /data/public: the view counts live at
/data/views.json, outside the directory that is served, and
mounting only the inner folder means they start again at zero every time the
container is recreated.
|
PUBLIC_BASE_URL |
The public HTTPS address, no trailing slash. Every link the publisher hands back is built from this, so it has to be the address from outside, not the LAN one. |
PUBLISH_TOKEN |
Required. A shared secret. Every write is refused without it, and
refused if it does not match. Reads stay public. Send it as
Authorization: Bearer <token> or X-Publish-Token.
|
CLOUDFLARE_ZONE_ID |
Optional. The zone your domain sits in. |
CLOUDFLARE_API_TOKEN |
Optional, needs exactly one permission: Zone · Cache Purge · Purge. Without both of these, unpublishing still deletes the file but a cached copy may serve for a while. |
CACHE_PREWARM |
Optional, on unless set to 0. After a clip is published the
publisher asks for its own public URLs once, so a CDN has the page and the start
of the video cached before the first person opens the link. It needs no
credential, does nothing without PUBLIC_BASE_URL, and range-requests
the video, so it costs a few megabytes per publish rather than the size of the
clip. Turn it off on a metered uplink.
|
DISCORD_WEBHOOK_URL |
Optional. A Discord channel webhook, from the channel's Integrations settings. A clip's first publish posts a card there with its name, its game, its marks and its poster; a takedown posts a short note. Re-uploading a clip under the same name is silent, and so is a trim. Treat it like the publish token: anybody holding it can post in that channel. Discord fetches the link and the poster to draw the card, so your address will appear in its logs. |
VIEWS_FILE |
Optional. Where the view counts are kept, /data/views.json in the
image. Outside the served folder on purpose, so it is never public. Mount
/data rather than /data/public or the counts start again
every time the container is recreated.
|
Endpoints
| Route | Does |
|---|---|
GET /api/health | { ok: true }, open. What the Test button in GoodBit calls. |
POST /api/publish | Multipart upload, field file, plus displayName and game. Token required. |
PUT /api/publish/:filename/thumbnail | The poster frame for the embed, a JPEG body. GoodBit sends the one it already made for its library card, so this server needs no ffmpeg. Token required. |
DELETE /api/publish/:filename | Removes the file and purges it from Cloudflare. Token required. |
PATCH /api/publish/:filename/metadata | Renames what the embed shows. |
GET /media/:filename | The file itself, with revalidation headers. |
GET /:filename | The player page with Open Graph tags. |
In front of it
Raise the upload body limit. A game clip is 50–250 MB and most proxies
refuse that by default, which shows up in GoodBit as a publish that fails with no
useful message. In Nginx or Nginx Proxy Manager's advanced box:
client_max_body_size 1024M;. In Caddy the default is already unlimited.
And the read timeout. Uploading 200 MB over a home connection can take
minutes: proxy_read_timeout 600s; and
proxy_send_timeout 600s;.
Terminate TLS at the proxy, forward to the container's host and port, and point
PUBLIC_BASE_URL at the public name. Then forward 80 and 443 on the router to
the proxy machine.
Then tell GoodBit
Settings → App → Publisher: the address, the token, Test, then Save. Publish appears in a clip's menu from then on.
On the same network as the server, give it the internal address
(http://192.168.1.20:5555) rather than the public one. The upload then never
touches the proxy, so the body limit and the read timeout above stop mattering, and it is
faster. Links are still built from PUBLIC_BASE_URL, which is the server's
business, not GoodBit's.
What this needs from you
Four things, and you probably have three of them.
- A machine that stays on. A NAS, a mini PC in a cupboard, a Raspberry Pi, a cheap VPS. It does not need to be fast. It hands out files. It does need to be awake when someone clicks your link.
- Docker on it. Synology and QNAP both ship it as an app (Container Manager); on anything Linux it is one install.
- A domain name. Any registrar, about £8 a year. You need one because a public link should be HTTPS, and a certificate needs a name. A free subdomain from DuckDNS works too if you would rather not pay.
- Access to your router, to forward two ports. If your internet comes over 4G/5G or Starlink you may be behind carrier NAT and cannot forward anything; in that case a small VPS is the way round it.
Nothing here is GoodBit-specific. It is the same list for any service you host at home, so if you already run one, skip to the reverse proxy step.
Fill these in once
Everything on the rest of this page is written with your own values in it, so you can copy commands rather than translate them. Nothing is sent anywhere; this stays in your browser.
Finding the path to an attached drive
On a Pi or any Linux box, this lists every disk and where it is mounted. A USB drive
is usually /dev/sda with a partition /dev/sda1.
lsblk -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT
If the MOUNTPOINT column has a path, that is the one to use. If the big
partition is mounted on / then the machine already boots from that
drive and there is nothing to mount: any ordinary path, such as
/srv/goodbit-clips, is on it.
If the column is empty the drive is attached but not mounted, and mounting it by hand does not survive a restart. Give it a permanent home instead:
sudo mkdir -p /mnt/ssd
sudo blkid /dev/sda1 # note the UUID
echo 'UUID=your-uuid /mnt/ssd ext4 defaults,nofail 0 2' | sudo tee -a /etc/fstab
sudo mount -a
df -h /mnt/ssd
Keep the nofail. Without it a machine whose drive is
missing refuses to finish booting, and on a headless box that means no shell to fix
it from.
The token is not optional. Serving clips is public, which is the
point; writing to the publisher must not be, or anyone who finds the address
can fill your disk and host whatever they like under your own domain. The publisher
refuses every upload until PUBLISH_TOKEN is set.
Something to paste, if you have no way of making one to hand:
openssl rand -base64 32
# or, on Windows PowerShell
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Max 256 }))
Leave any of them blank and the commands below keep a sensible placeholder, so you can come back and fill it in.
Start the publisher
Nothing to clone and nothing to compile: the image is on Docker Hub, built for both
amd64 and arm64, so this covers a NAS, a Pi and an ordinary PC alike. Save this as
docker-compose.yml somewhere on the server and run
docker compose up -d.
Or build it yourself
If you would rather not pull a stranger's container, build the same thing from
source and change image: above to goodbit-publisher.
git clone https://github.com/DarrellVS/goodbit.git
cd goodbit/publisher
docker build -t goodbit-publisher .
Check it is alive, from the machine GoodBit runs on rather than from the server, since that is the path publishing will actually take:
You want {"ok":true}. If you get nothing, the container did not start,
docker compose logs will say why, and it is almost always the storage path
not existing or not being writable.
Those logs also say whether the token arrived. A publisher that started with one says
PUBLISH_TOKEN is set, so uploads are protected; one that started without
prints a block saying it will refuse every upload. Worth a look either way, since a
token lost to a stray quote or an env_file that is not being read looks
exactly like a working server until the first clip.
Not reachable from the internet yet, and that is correct. Right now it only answers on your own network. The next two steps are what change that, in the right order: a way in, then something to guard the door.
Forward two ports
Your router hands every device on your network a private address and hides them all behind one public one. Forwarding a port tells it: traffic arriving on this port goes to that machine.
Forward 80 and 443 to the machine that will run the reverse proxy in the next step, not to the publisher's port. 80 is only there so the certificate can be issued and renewed; 443 carries everything real.
Never forward the publisher's own port. It speaks plain HTTP and has no authentication on uploads, because it is built to sit behind something that does the TLS and the blocking. Exposed directly, anyone who finds it can fill your disk.
Every router calls this something different. Yours:
While you are in there, give the server a fixed address, a DHCP reservation, sometimes called a static lease. Otherwise it changes one day and the forward quietly points at a printer.
Last, point your domain at your home: an A record for
clips.example.com pointing at your public IP. If that IP
changes, a dynamic DNS updater (most routers have one built in) keeps it honest.
Put a reverse proxy in front
A reverse proxy answers on 443 with a valid certificate and passes the request inward to
the publisher. It is what turns http://192.168.1.20:5555 into
https://clips.example.com.
If you have no preference, Nginx Proxy Manager is the gentlest: it runs in Docker, has a web interface, and gets Let's Encrypt certificates for you without a command line.
In Nginx Proxy Manager
- Hosts → Proxy Hosts → Add Proxy Host.
-
Domain Names:
clips.example.com - Scheme:
http, inside your network, plain is fine. - Forward Hostname / IP:
192.168.1.20 - Forward Port:
5555 - Block Common Exploits: on.
- SSL tab → Request a new SSL Certificate, tick Force SSL and HTTP/2 Support, agree to the terms, Save.
Then the part everyone misses. Open the host again and find Custom Nginx Configuration: in current versions that is the cog at the right-hand end of the tab strip, next to SSL, rather than a tab called Advanced as it was before. Paste this in and save. Without it the proxy refuses anything over 1 MB and publishing a clip fails with nothing useful in the app.
client_max_body_size 1024M;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
Already have Caddy or Traefik?
Caddy needs two lines and handles certificates and body size on its own:
Whatever you use, confirm it works before moving on: open
https://clips.example.com/api/health on your phone with wifi
off. You want {"ok":true} over a padlock.
If GoodBit and the publisher are on the same network, skip the proxy for
uploads. Give GoodBit the internal address,
http://192.168.1.20:5555, rather than the public one. The
upload then goes straight to the container over your own network: no body limit to
raise, no timeout to extend, and it is faster. The public address is still what
links are built from, because that comes from
PUBLIC_BASE_URL on the server, not from what GoodBit was told.
Cloudflare cache purging (optional)
Skip this unless your domain is on Cloudflare. Everything works without it; this only fixes one thing.
Cloudflare caches your media at its edge, which is good. The clip comes from a server near whoever opened it rather than from your cupboard. But when you unpublish a clip, deleting the file does not remove the copy Cloudflare is holding, so the link keeps working for a while. Give the publisher a token and it tells Cloudflare to drop that copy the moment the file goes.
Make the token
- Open dash.cloudflare.com/profile/api-tokens, which is your profile's API Tokens page, and press Create Token.
- Scroll to the bottom: Create Custom Token.
-
Permissions:
Zone·Cache Purge·Purge. That one row, nothing else. It is the whole reason a token is safer here than an account key. - Zone Resources: Include · Specific zone · your domain.
- Create, then copy the token. It is shown once.
The Zone ID is on your domain's overview page in the Cloudflare dashboard, in the right-hand column, under API.
Add both to the compose file and restart:
CLOUDFLARE_ZONE_ID: "your-zone-id"
CLOUDFLARE_API_TOKEN: "your-token"
docker compose up -d
With one missing or wrong, the publisher carries on and simply does not purge; it will not break publishing.
Two things worth doing in the dashboard
Neither can be set from here, because neither is something an API token with one purge permission is allowed to touch. Both are one click.
- Smart Tiered Cache, under Caching · Tiered Cache. It gives your zone an upper tier in front of the origin, so the pre-warm above warms one cache that every edge location then pulls from, instead of warming whichever location the publisher happened to reach. Without it the pre-warm still helps, it just helps in one city.
- Check it is working. Publish a clip, wait ten seconds, then ask for the video and read one header:
curl -sI -r 0-1 https://your-domain.com/media/your-clip.mp4 | grep -i cf-cache-status
HIT is the answer you want. MISS on the very first ask is
normal; MISS every time means the pre-warm is not reaching this address,
which is nearly always PUBLIC_BASE_URL pointing at the LAN name rather
than the public one. The publisher logs one line per warmed URL with the same header
in it, so its own log will say the same thing.
The embed page itself is deliberately not cached at the edge and will always
read DYNAMIC. It is a couple of kilobytes built per request, and it is
compiled into the server, so a publisher update changes the page of every clip you
have ever published and there is no list of them to purge.
Tell GoodBit about it
Fill in the internal address and the token on the Details step to use this.
Server somewhere else? Hand over the public address instead, though the upload then goes through the proxy.
Or by hand
In the app: Settings → App → Publisher.
-
Paste the address. On the same network as the server, use
http://192.168.1.20:5555, which skips the proxy for the upload; from anywhere else usehttps://clips.example.com. -
Paste the publish token,
the one you invented earlier, into the field under it. Without it every upload is refused. - Press Test. It should say That address answers.
- Press Save.
Publish now appears in a clip's ⋮ menu and in the share
sheet, and the link lands on your clipboard as soon as the upload finishes. By default
what goes up is a compressed copy, a fifth of the size, so it opens quickly for
whoever you sent it to, while the file on your disk is untouched. That is the
Compress clips when publishing switch, in the same settings screen.
If it does not work
| What you see | Almost always |
|---|---|
| Test says no answer |
DNS not pointing at you yet, the port forward missing, or the proxy host not
saved. Try /api/health in a browser on mobile data.
|
| Publish fails on big clips, works on small ones |
The proxy's body limit. client_max_body_size, see the previous
step.
|
| Publish hangs, then fails | The proxy's read timeout, on a slow upstream connection. |
| Link opens but the video does not play |
PUBLIC_BASE_URL is wrong. It must be the public HTTPS address,
with no trailing slash.
|
| Unpublished clips still open | The Cloudflare step, or a browser holding its own copy. Hard-refresh first. |