How I built a public fax wall
There is a working fax number out in the world and it belongs to me. If you send a page to it, that page may end up hanging on a web page, in black and white, for any stranger to look at. No signup, no login, no app. Just a phone number and an old machine on the other end of an imaginary line.
The idea came out of a small grudge. "Just send the link" has become the beige wallpaper of modern life. Everything is instant, everything is editable, everything disappears. Fax isn't like that. Fax is stubborn, far too physical for 2026, and somehow still alive — running like a haunted appliance with union protection. I wanted to see what would happen if I left that channel open to the world and published whatever came through without much curation.
This is about how the thing was built. I'll walk through each piece in the order a sheet of paper travels through the system.
The rule that shaped the architecture
Before the first file existed, I had one personal constraint: no application server.
No backend running around the clock, no database to back up, no container to patch at three in the morning because somebody published a CVE. This is a toy project. If it demands maintenance, it dies in three months. They all do.
So the decision was: the final site is static HTML. One file. Nginx serves it and that's the end of it. Everything dynamic happens far away, on infrastructure somebody else maintains for free, and the result lands as a dumb JSON file on disk.
That sounds like laziness. It is, but it's laziness with a design behind it. Every other choice in the system falls out of this rule.
Piece 1: getting a real fax number
I don't have a phone line and I'm not buying a machine. I used a fax-to-email service — fax.plus, in this case. You rent a number, and when somebody transmits to it, the service answers the call, decodes the signal, and emails you the document as an attachment, PDF or TIFF.
The number is published on the site, because that's the entire point: +1 762 475 9826.
Here's the trick that made the project viable: the moment a fax becomes an email with an attachment, it stops being telephony and becomes a Gmail inbox. And a Gmail inbox is something I know how to automate without paying anyone.
Piece 2: Google Apps Script playing the part of a backend
The whole core of the system lives in a single Código.gs file running on Google Apps Script. It's JavaScript hosted by Google with native access to Gmail, Drive, and Sheets, time-based triggers, and a public HTTP endpoint if you want one. It costs nothing and I administer none of it.
It has two main functions that run in sequence.
ingestFaxes() — fishing out the new faxes
The function sweeps Gmail with a specific query: messages from the fax service's notification sender, within the last 30 days. The search is paginated 100 threads at a time in a loop, because the Apps Script API hands results back in batches, and ignoring that is the classic way to silently lose older messages once volume grows.
For each message, three decisions:
Have I seen this one? Before anything else, I compare the Gmail message ID against the IDs already on record. This is the heart of the system's idempotency. The trigger fires periodically and always looks at a rolling 30-day window, which means it reprocesses the same messages dozens of times. Without a reliable dedup key, the wall would turn into a wall of duplicates. Using the message ID rather than the subject or the date was the right call: subjects repeat, dates collide, IDs don't.
Which attachment is the fax? A notification email arrives with a logo, a footer, an inline image, sometimes a receipt. I wrote a function that filters for plausible candidates — .pdf, .tif, .tiff, or the matching content types — and then scores the survivors: PDF is worth 3, TIFF is worth 2, and file size enters as a tiebreaker divided by ten million. Highest score wins.
There's one ugly case in there that I left on purpose: if an attachment shows up as application/octet-stream and is larger than 1 KB, I accept it. Fax gateways are notorious for sending generic content types. Rejecting on technical purity would mean dropping legitimate faxes, and the cost of the opposite error is low — worst case, Cloudinary refuses the file downstream and the row simply ends up without an image.
Keep the original. The chosen attachment is copied into a Drive folder under a deterministic name: fax_20260410_143022_<messageId>. That's the raw file, untouched. If I break every other stage of the pipeline, the source material is still sitting there.
Then a row goes into the spreadsheet with its own UUID, the received date, the sender, the subject, the Drive file ID, the attachment name and type, and a short public slug derived from the first eight characters of the UUID.
The spreadsheet as a database
Yes, the database is a fifteen-column Google Sheet. I recommend this far more often than people expect for projects this size. It comes with a free admin interface, revision history, permissions, and export, and I can fix a bad record from my phone on the bus without touching SSH.
One thing I did that saved me pain: a function that checks and repairs the sheet's header row every single time the script runs. It verifies the fifteen columns are where they should be, inserts columns if any are missing, and rewrites the header row if it doesn't match. A spreadsheet is human-editable, and humans drag columns around. Assuming the schema is intact is optimism. The code never reads a column by fixed position — it always builds a name-to-index map from row 1.
Every row is born with status approved. The schema supports moderation — the field is there, and the publishing function only looks at approved rows — but in practice the gate is wide open. That was a deliberate choice about the spirit of the site, not an oversight. The switch exists for the day I need it.
publishApproved() — turning a PDF into a publishable image
Second function. It looks for approved rows that don't have a published image yet, pulls the original file from Drive, and pushes it to Cloudinary under a predictable public ID.
And this is where my favorite part of the whole project lives, because it's work I didn't have to do.
The real problem was: how do you turn a fax PDF into an image that fits on a web page? The obvious answer involves rasterizing PDF, which means ImageMagick or Ghostscript, which means a server, which breaks rule number one.
Cloudinary does it in the URL. After the upload, the public image is assembled from four transformations chained into the path:
pg_1— take only the first page of the PDFdn_200— rasterize at 200 DPI, fax densitye_blackwhite— force pure black and white, no halftoneq_auto— let Cloudinary pick the compression
No processing happens on my side at all. I upload the raw PDF and request an image by URL. The conversion runs on their CDN, on demand, and gets cached. The result looks exactly the way I wanted — fax grain, high contrast, none of that tasteful gray.
The image URL and the public ID go back into the spreadsheet, and the row is live.
Piece 3: privacy, or at least the facade of it
The wall displays pages that strangers sent me. I have no idea what's going to arrive. So there's a hygiene layer working at two levels.
On the server side, the sender runs through a function that tries to extract a human name from the email's From field. It handles the "Some Person" <person@domain.com> format, strips outer quotes, and then applies two rejection tests: if what's left looks like an email address, discard it; if it looks like a phone or fax number — seven or more digits, nothing but phone characters — discard it. Anything suspicious becomes Unknown sender.
This matters more than it looks. Fax carries the originating number in its header, and the service frequently drops that number straight into the sender field. Publishing it would mean leaking the phone number of whoever sent me a joke.
On the client side I went further: the page doesn't render the sender name at all. The public JSON carries the field, the front-end simply never draws it. Every entry on the wall shows the date, the image, and an optional note. That's it. As the site's own FAQ puts it — the machine knows, obviously, but it is not a snitch.
Piece 4: how the data leaves Google and reaches my VPS
The Apps Script publishes a Web App with three output formats, selected by URL parameter: a rough HTML gallery, format=json, and format=rss. The last two are the ones that matter. Both accept a per-item filter and a count limit.
Now, I could have just pointed the site at Google's endpoint and called it done. I didn't, for three reasons: the URL is ugly and advertises the implementation, Apps Script has execution quotas, and I don't want my site's availability to depend on a Google redirect resolving during a visitor's request.
So there's an eighty-line shell script running hourly on the VPS via cron. It's small, but there are four deliberate decisions inside it.
Cascading configuration. The script loads config files in order — repository first, then ~/.config — and environment variables override everything. That gives me versioned defaults plus machine-local overrides without a single if.
Explicit failure. It opens with set -euo pipefail and aborts with a clear message if the Web App URL isn't configured. A silent cron job is the worst category of bug: the site just freezes in time and nobody notices for three weeks.
URL rewriting. Every link pointing at Google's domain inside the payload gets swapped for the public domain, using perl with both ends passed in through environment variables instead of interpolated into the regex. That keeps a slash or a question mark in the URL from turning into a metacharacter and destroying the file.
Atomic writes. This is the most important one and the easiest to forget. curl downloads to faxes.json.tmp, and only then does mv move it over the real file. mv within the same filesystem is an atomic rename, which means nginx never — not for a single instant — serves a half-written JSON. If you download straight over the file being served, sooner or later a visitor catches the transfer mid-flight and gets a parse error.
Nginx serves both files with Cache-Control: max-age=3600, deliberately aligned to the cron interval. There's no point caching for longer than the refresh, or for shorter.
Piece 5: the page
The front-end is a single HTML file. No build step, no framework, no dependencies, no npm install. It loads the JSON with fetch and assembles the list.
The look is monospace, light gray background, blue underlined links in the browser's original default. That's not aesthetic laziness — that is the aesthetic. A fax wall shouldn't look like a SaaS product.
A few details worth mentioning:
The favicon is a fax emoji embedded as an SVG data URL. Zero extra requests, no .ico file.
The image containers have aspect-ratio: 210 / 297 locked in before any image loads. That's the A4 ratio. The space is reserved at the correct size up front, so nothing jumps around when the images arrive.
The first image loads with loading="eager" and fetchpriority="high"; every other one is lazy. There's a preconnect to Cloudinary's domain in the head, so the TLS handshake is already underway before the JSON finishes downloading.
The JSON parser is intentionally forgiving: if the response is an array, use it; if it's an object, look for items, faxes, records, data, or entries. It cost six lines and lets me change the backend's shape without breaking the live page.
And everything coming out of the JSON gets HTML-escaped before it touches the DOM. I am rendering content that anonymous strangers transmitted over a phone line. Concatenating that straight into innerHTML would be an open invitation for the first clever visitor to write the rest of my site for me.
Piece 6: the doorman
Sitting in front of all of it is Anubis, a proxy that demands proof-of-work from the browser before it lets the page through. It's there because AI training crawlers started burning through small-VPS bandwidth as if it were infinite.
I found out it was stricter than I remembered when I tried to fetch my own site with an automated tool and got an "access denied" to the face. Working as designed, I suppose.
The whole flow, in one line
Somebody dials the number → the service converts the call into an email with an attachment → Apps Script finds the email, dedupes on the message ID, picks the right attachment, archives the original in Drive, and writes a row to the spreadsheet → it uploads the PDF to Cloudinary and requests page one in black and white at 200 DPI → it publishes a JSON feed → the VPS cron pulls that JSON hourly and atomically swaps it over the old one → nginx serves it → static HTML draws it.
Not one process of mine runs continuously. The most complex part of the system — rasterizing PDF — is a parameter in a URL. The part that most resembles a database is a spreadsheet I can open on my phone.
What I'd do differently
Two things bother me when I reread the repository.
First: the Web App URL is committed to the environment file inside the repo, and the README itself says it should live outside of it. It's a read-only endpoint, so it isn't catastrophic, but it's exactly the kind of thing you swear you'll fix later and never do. The right move is to purge it from history, publish a new Web App version, and keep the value only in ~/.config on the VPS.
Second: the data/faxes.json sitting in the repo is empty. It's a placeholder — the real file is generated on the VPS by cron — but a committed empty file is a trap waiting for someone to deploy it over the good one.
Beyond that, I'd keep all of it. Especially the part where there's no server to maintain.
Member discussion