nido: Frontend Infrastructure on a Small Server
During a Lunar New Year break, I had too much free time and bought an Alibaba Cloud server. I remember it costing about RMB 69: two CPU cores, 2 GB of memory, and only 40 GB of disk space. Resources were tight, yet I still wanted to put more things on it.
I needed to publish the frontends, make single-page application (SPA) routes work, and keep the backends running. To fit these sites onto the small server, I built tools to manage frontend sites, starting with releases and SPA deployment.
The project isn’t public yet. I’ll call it nido here; perhaps that will become its name later. It isn’t open source, mainly because I haven’t had the time. I may release it in the future.
My wife and I have a small website similar to X, used only by the two of us. We write about daily life, what we’re thinking, and what we’re unsure about. Looking back ourselves, or perhaps sharing it with children in the future, we could see what was on each of our minds at that time. We also have a little app for recording what we cooked each day and how much we spent.
We also have a Doro bot on QQ, built with OpenClaw and hosted on Tencent Cloud. It used Doubao at the time, and I liked how naturally it spoke. Once, when the server was nearing its expiration date, I asked it a few questions. Its replies almost made me cry, so I renewed the server for another year.
After I changed the model, Doro seemed to lose some of that spark. These days it’s more of a scheduled-task runner: days until the next holiday, the weather, and occasionally converting a video to a GIF or changing a file format. I probably won’t renew it when the server expires next year. I’ll miss it a little.
Few people use these sites, but they hold things I want to keep. Deployment, access, and keeping them running are all my responsibility.
Taking some weight off the small server
nido now handles the frontends. The application backends still run on Node.js, with PM2 managing the processes. GitHub Actions usually builds the frontends: I didn’t have the resources for my own CI/CD setup, so I used GitHub. OSS object storage holds the build outputs, and a CDN can distribute the static assets.
That moves the builds elsewhere and keeps every website version from piling up on the 40 GB disk. A CDN is better suited to distributing static files than leaving the small server to handle them alone. Some website requests still need server-side processing, though. Actual throughput needs benchmarking; the design alone can’t tell me how much faster it is.
nido has three parts. The console shows projects, build logs, versions, domains, and scripts. The management service stores configuration and handles website routing and pages. The release tool uploads files that have already been built, then confirms that the version is ready. It doesn’t build source code into a website itself.
GitHub Actions is one way to connect these parts. The release tool also has a CLI, so I can build locally and upload the output. A project whose build needs the version and asset location reserves a version first, then builds for that location.
Publishing frontend files this way doesn’t require stopping the application backend along with them. It reduces the overlap between frontend and backend releases, while a single server still has availability limits.
Opening the homepage isn’t the whole job
A SPA is a single-page application: it loads an entry page, then JavaScript in the browser changes the content. Following a link from the homepage to /settings may change the address without requesting a whole new webpage from the server.
Refreshing is different. The browser requests /settings directly, but the build may contain only index.html, with no file at that path. Looking it up as a file gives a 404. The server needs to support this kind of routing (opens in a new tab) by returning the entry page for application routes, so the frontend can decide what to show.
nido’s early fallback handling also fetched files by path. I later added routing settings for each project: application paths in history mode go to the entry page, while scripts, styles, and images are fetched as resources. Hash routing and static websites are handled separately.
Returning index.html for everything missing would be a shortcut with consequences. It makes sense for /settings; HTML in place of a missing /assets/app.js still won’t run. Application routes and asset errors need different treatment.
Another fix involved proxy responses. Axios automatically decompresses responses in Node.js by default (opens in a new tab), but I was also copying the upstream Content-Length header. The body grew after decompression, while the header still described the compressed size. That could truncate the resource. The fix removed the outdated length and encoding headers, rather than forwarding upstream headers unchanged.
For missing pages, I can also design a shared fallback 404 page, instead of each small site showing a different default error. The frontend still decides which routes don’t exist inside its SPA, and a pretty 404 webpage can’t stand in for a missing script.
Historical and candidate versions
Each build has its own version and storage location. After the upload finishes and its result is confirmed, I can decide whether to publish it. A partly uploaded directory doesn’t count as a ready version, and uploads don’t overwrite the files readers are using.
Readers can stay on A while B remains a candidate, with a preview address to inspect it. Opening both side by side makes changes to layout or interaction easier to see.
B can be ready while the live site still uses A.
Historical versions feel a little like a time machine: I can reopen an earlier frontend build. In the console, I can go through them one by one, like flipping through slides of how the site changed. Though, uh, normal people probably don’t do this.
The version addresses still use a very rough v-x design; I want to improve that later. In a company’s test environment, this could also put a candidate beside the live version to see whether a change broke something.
But this time machine stores frontend builds, not the complete state of a website on a particular day. An old page calling the current backend may still show current data. The things my wife and I wrote need to be preserved in the application’s data; old webpages in OSS don’t back up those records for us.
For a canary release, some visits can use the candidate while the rest stay on the stable version. nido supports conditional routing rules. The important part is keeping the HTML, scripts, and styles in a page load on the same version, while ensuring a shared cache doesn’t serve one visit’s candidate page to everyone.
Existing service worker caches need attention too. Switching the version reference doesn’t mean the browser has forgotten its old files. Version consistency needs asset addresses and caching to be designed together; showing B on the homepage isn’t enough.
Rolling back selects a retained version, avoiding another build and upload. But an easy reference change doesn’t mean every cache synchronizes within milliseconds. Restoring the webpage also doesn’t undo data already written to the backend.
Once I have the HTML, I want to add things
As I worked on it, I found much more room to play with SPAs than I’d expected. Before a page reaches the browser, I can add things I want. Script insertion suits the way I like to work, and I find it useful.
An analytics script or page update notice shared by several sites can live in one script library, with each site choosing whether and where to insert it. There’s no need to edit and rebuild every project separately. Insertion doesn’t guarantee that every script will run: third-party scripts need their own configuration, and a page’s CSP (opens in a new tab) may restrict them.
The update notice is one example already in use. A new release doesn’t replace a page someone has open. The notice compares the version recorded in the page with the version returned by the server, and appears in a corner only when they differ. Readers can refresh or dismiss it for the rest of that page visit.
If you’ve left my blog open for a while, you might have seen this little notice.
The actual notice component rendered on a blank test page, with a simulated version change.
Checking takes only the response headers, without downloading the whole page again. Shadow DOM keeps the notice’s styles separate, so a site-wide change that enlarges every button doesn’t enlarge its Refresh button too. Failed requests are left alone, rather than treating a network glitch as a new version.
Scripts and frontend builds have separate lifecycles. Editing a shared script affects pages served next, while switching to an old webpage version doesn’t roll the script library back. That convenience also means knowing which sites use a script before changing it.
The console holds analytics too: I can start with a traffic source and see which pages visitors opened, or start with an article and check its sources. Several projects can share a chart scale. PV counts page visits; UV deduplicates browser identifiers. Changing browsers or clearing storage may produce a new identifier. It isn’t an exact headcount, and it doesn’t prove someone finished an article. Visits that weren’t collected can’t be reconstructed, and the tracker respects the browser’s Do Not Track signal.
Domains, routing, scripts, versions, and a shared 404 can now be managed together, and basic site access gates are implemented too. More detailed application permissions remain each app’s responsibility. I’ve started to think of this as frontend infrastructure for my own small projects.
I began with tight resources and wanted to squeeze as much out of them as I could. As more possibilities appeared, it became more interesting than simply uploading webpages. I might consider serverless for the application backends later. For now, they still run on Node.js and PM2.
Those little sites may never have many users. I still want to keep them. They hold a record of our own days together.
Comments