A wishlist in Go: how it works

· News · Читать на русском

I now have a wishlist — a page where you can pick a gift for me. The idea was simple: I add links to things from any online shop, a guest picks a gift and marks it as theirs, and the card turns grey so nobody else buys the same thing. No sign-up, no names, no "so what are you getting?" messages.

It is built the same way as this blog: a small Go program in a Docker container on a Raspberry Pi at home. Below is how it works, what went in, what was deliberately left out, and why.

What it had to do

The requirements fit in one paragraph: an admin panel where I paste links to Amazon, eBay or any other shop; for guests, a list with pictures, prices and shops; an anonymous reservation that makes the gift unavailable to everyone else. Plus a hint on where to send the gift: from the US, to my US address; from anywhere else, to my Canadian one.

Numbers

Measured on the Raspberry Pi on 2 November 2026:

Process memory 24 MB
Program 9.3 MB
Whole Docker image 18.5 MB
All data (wishlist.json) 15 KB
Home page about 4 ms

One binary, zero dependencies

The whole wishlist is one Go program. Templates, styles, the script and the icons are embedded in it, so not a single file sits next to it on the server. There are no external dependencies at all: go.mod holds only the module name and the Go version. The HTTP server, templates, parsing shop pages, the SMTP client, password hashing — all of it comes from the standard library.

Left out: web frameworks, ORMs, front-end build tools and third-party libraries in general. Every extra dependency is one more thing that will break on an update some day.

No database

Gifts, reservations and settings live in one file, wishlist.json. It is held in memory in full and written atomically on every change: first to a temporary file, then renamed. If the power goes out mid-write, the disk holds either the old version or the new one.

A reservation is made under a single lock: checking "is this gift still free" and writing the reservation are one indivisible step. So two guests pressing the button at the same moment can never take the same last unit.

Left out: any database server. My whole wishlist is 15 kilobytes.

Reserving without an account

A guest taps a card and gets to the next step: the shipping rules and an email field. After reserving, they get an email with a link to their reservation, where they can see the shipping address and the shop link, mark the gift as bought, or cancel. One reminder arrives three days before the reservation runs out. A reservation lasts 14 days unless the gift is marked as bought; after that the gift is free again.

The browser remembers its own reservations, so the guest sees "You're gifting this" on the card.

An email is required to reserve: the button stays disabled until a valid address is entered, and the server will not accept a reservation without one. The reservation link is the only way to manage it, and in an email it will not get lost. The guest's address is kept only until the gift is marked as bought.

Left out: guest sign-in with Apple or Google, names and accounts. You do not have to introduce yourself to give a gift.

Where to send the gift

After reserving, the guest sees the address as separate fields — name, street, city, postal code — each copied with one tap, plus a Copy All button. The shop decides which address is shown: a US shop ships to the US address, a shop in any other country to the Canadian one. If a gift is tied to one country, only that address is shown. For gifts without one, the default address is picked by the visitor's country as reported by Cloudflare.

There is one more option: order the gift to your own address and hand it over in person. This is said before reserving, together with the shipping rules.

The addresses themselves live in the data on the server and are edited in the admin panel; they are not in the repository.

How shops are read

I paste a link into the admin panel, and the program fetches the title, picture and price by itself. It turned out that every shop has to be read in its own way:

Shop How
amazon.com Seen from Canada, amazon.com hides the price of items that do not ship here. So once per session the program sets a US delivery ZIP through Amazon's own "Deliver to" dialog, and the price appears.
amazon.ca The product page, price in Canadian dollars.
Discogs The site is behind a Cloudflare challenge, but there is an open API that needs no key. For a release it shows the cheapest marketplace offer as "from $X"; for a single listing, its price.
Any other shop OpenGraph tags and schema.org Product markup on the page.
eBay, Casio, Best Buy, LEGO They refuse every client except a real browser. The card is created with a note, and I fill in the title, price and picture by hand.

Pictures are downloaded once and served by the program itself, so the cards do not depend on what a shop does with its image links.

Left out: getting around bot protection. Sites that have shut programs out stay manual.

Prices every morning

Every day at 06:00 the program reads the prices of all gifts again; if the server was off for more than a day, it does so one minute after start. A price is only replaced by a price actually read from the shop's site: what I typed in by hand for the "closed" shops stays as it is.

When Amazon has no offer of its own for an item (only third-party sellers under "See All Buying Options", or "Currently unavailable"), guests see "Temporarily unavailable in store" instead of a price. As soon as the morning check reads a price again, it comes back to the card.

Left out: price-drop emails. The idea was mainly for Discogs, but its API cannot give the lowest price among Mint or Near Mint copies only — and those are the only ones to buy (more on that below).

Records from Discogs

Discogs sells used records, cassettes and CDs, and their condition varies a lot. So gifts from Discogs come with a rule: Mint or Near Mint only, for both the media and the sleeve. The card says so in one line, and the reserve step and the reservation page have a collapsed block with Discogs' own definitions of these grades for the format the release came out on: vinyl, CD or cassette. Russian-speaking guests see a translation, everyone else the original.

The order of the list

The gifts I want most are marked ★ Really want: on the site they sit above the rest and have a red frame. Reserved gifts move to the end of the list.

In the admin panel the list has two parts: ★ on top, everything else below. Rows can be dragged with a mouse, a finger or the arrow keys, and moving a row from one part to the other sets or clears the ★. Besides manual order, the list can be sorted by date added, title or price. Prices in different currencies are compared in Canadian dollars at the Bank of Canada's daily average rate, which the program reads from the bank's open API.

Two languages

The site and the admin panel are in Russian and English. The language comes from the browser, and a switch remembers the choice. Russian numbers take the right word forms by the Unicode CLDR rules ("1 день, 2 дня, 5 дней"), with no abbreviations.

The card shown for links on social networks and in messengers is English only: "Pick a gift to make my day".

Email

Guests get an email with the reservation link and a reminder; I get notices about reservations, purchases, cancellations and expired reservations, naming the gift. Mail is sent by the program's own small SMTP client, only over an encrypted connection (STARTTLS), and delivered by Resend, a service for application mail.

Left out:

  • iCloud Mail. At first the emails went out through my iCloud mailbox. They reached Apple addresses, but Gmail rejected them with 550 5.7.1 [CS101] Message rejected due to local policy.
  • Telegram notices. Emails to a separate address replaced them.
  • A "spoiler-free" mode in which the admin panel would not show me what I am getting. It existed, but I removed it: the public page shows "Someone is gifting this" to anyone with a phone, so hiding reservations from myself made no sense.

Security and privacy

  • Pages are closed to search engines (noindex). The bots that build link previews for social networks and messengers may read the public pages — otherwise a shared link would look like a bare address. The admin panel and reservation pages are closed to them too.
  • At most 5 reservations per hour and 5 admin login attempts per 15 minutes from one address.
  • The admin password is stored as a PBKDF2-SHA256 hash (600,000 iterations) from Go's standard library; the session is an HMAC-signed cookie, with no session store on the server.
  • Scripts and styles load only from the site itself (Content-Security-Policy), and forms are accepted only from the site's own pages.

Docker and the tunnel

The image is built in two stages: in the first, Go compiles a static binary; in the second, it is copied into a clean Alpine, where the only extras are certificates and time zones. The container runs as an unprivileged user, and all data sits in one volume: wishlist.json, the session key and the pictures.

The image is built straight from the GitHub repository, so updating the server is one command:

docker compose build --no-cache && docker compose up -d

Before that, every code change is checked on GitHub: formatting, go vet, tests with the race detector and a trial build for ARM64, the Raspberry Pi's architecture.

The site reaches the outside world through Cloudflare Tunnel: a separate cloudflared container on the same Docker network, so no ports had to be opened on the home router.

Source code

The wishlist's code is open: github.com/WhyElEss/wishlist