shiqi.si goes live: from an empty domain to a running server
All times are UTC. This is the full log of the day, wrong turns included, because the wrong turns are where most of the learning was.
The brief (03:13)
Build a pixel-art personal site for the domain I already own, shiqi.si: lively but not cluttered, gold / green / black / white, inspired by the macOS "1984" pixel wallpapers and Sam Cox's doodles. Little personal info, some toys, some practical tools, room for notes. Follow-ups during the morning added:
- React, with a reusable component and theme library (day, night and more), because I like code reuse.
- Treat it as a long-term sandbox: APIs, course notes, online quizzes and projects later.
- No GitHub Pages. I want a real cloud server.
- The server should eventually host SSH, Docker, Redis, Kafka and Kubernetes.
- The background should move, like the Macintosh wallpaper.
Choosing a free host (03:26)
Options checked for a Georgia Tech student:
- Azure for Students: sign up with the school email, no credit card, USD 100 of credit per year while enrolled, plus 750 hours a month of a small B-series VM free for 12 months. Chosen.
- DigitalOcean via the GitHub Student Pack: the USD 200 credit ended on 2026-07-31, so it is out.
- Oracle Cloud Always Free (Arm): much more memory, which Kafka and Kubernetes need, but it asks for a credit card for verification and popular regions often have no Arm capacity. Kept as the plan for a second, bigger node.
The VM size: Standard_B2ats_v2, 2 vCPU and 1 GB of RAM. That number decides most of the rest of the day.
The code (03:13 to 03:54)
A pnpm monorepo:
apps/web: React Router in framework mode, server-rendered on Node, TypeScript strict. Notes are MDX files.packages/ui(@shiqi/ui): every component and the CSS-variable theme system (day, night, 1984 gold, matcha).packages/pixel(@shiqi/pixel): framework-free pixel drawing: icons, the animated desktop background, the doodle generator, the daily critter.infra/: deployment scripts.docs/deploy.md: the launch checklist.
Content windows float over an animated 1984-style icon desktop; the animation stops when the OS asks for reduced motion. Tests, type checks and the build passed locally before anything was deployed.
Creating the VM (03:33 to 03:42)
The VM was created from Azure Cloud Shell (the Bash terminal in the Azure web portal) with az vm create. It needs an SSH public key, and the Mac had none:
cat: /Users/saigezhang/.ssh/id_ed25519.pub: No such file or directory
So the first step was ssh-keygen -t ed25519 -C "shiqi.si". I made one key without -C, then regenerated it with the comment. Lesson: the comment is only a label. ssh-keygen -c -C "new comment" -f ~/.ssh/id_ed25519 changes it in place, and regenerating changes the key itself, so the new public key has to replace the old one everywhere. More in SSH keys and knowing which machine you're on.
The first create failed:
(RequestDisallowedByAzure) Resource 'shiqi-1VNET' was disallowed by Azure:
This policy maintains a set of best available regions where your subscription can deploy resources.
Student subscriptions only allow a short list of regions. This command lists them:
az policy assignment list --query "[].parameters.listOfAllowedLocations.value" -o tsv
eastus was not on it; northcentralus (Chicago) was. The resource group had already been created in eastus, which does not matter: a group's location is only metadata, and -l northcentralus on the VM is enough.
az vm create -g shiqi -n shiqi-1 -l northcentralus \
--image Ubuntu2404 --size Standard_B2ats_v2 \
--admin-username saige \
--ssh-key-values "ssh-ed25519 AAAA... shiqi.si" \
--public-ip-sku Standard --os-disk-size-gb 64 --storage-sku Premium_LRS
az vm open-port -g shiqi -n shiqi-1 --port 80,443 --priority 900
Result: Ubuntu 24.04, public IP 52.162.142.136. A Standard public IP is static, so it survives reboots. The network security group allows 22 (added by az vm create) plus 80 and 443.
First SSH (03:43 to 03:44)
The first connection asks to trust the server's host key fingerprint. Answer yes once; it goes into ~/.ssh/known_hosts.
The first attempt was from Cloud Shell and failed with Permission denied (publickey): the private key lives only on the Mac. From the Mac's Terminal, ssh saige@52.162.142.136 logged straight in.
DNS (03:42 to 04:02)
At the registrar, two A records, both pointing at the VM:
@→52.162.142.136www→52.162.142.136
TTL is in seconds; 300 means 5 minutes. The registrar showed "Not Yet Propagated" for a while, which is normal. Check with dig +short shiqi.si. See DNS: A records and TTL.
Running it locally on the Mac (03:55 to 04:03)
Several commands meant for the Mac ran on the server instead (the prompt said saige@shiqi-1, the path was /home/saige), and brew "was not found" because Ubuntu has no Homebrew. Once back on the Mac:
- Homebrew was already there and Node 26 was installed.
corepack: command not found: Node 25 and later no longer ship Corepack.brew install pnpminstead, thenpnpm install && pnpm dev.
GitHub and CI (03:55 to 04:02)
I created an empty public repository, pajarnas/shiqi.si, and connected it to Claude, which pushed the initial commit to main. The first GitHub Actions run passed: format, lint, type check, tests, build, then a multi-arch Docker image pushed to ghcr.io/pajarnas/shiqi.si. Because the repository is public the image is public too, so the server can pull it without credentials.
Bootstrap round 1: k3s (04:07 to 08:09)
The original plan was single-node Kubernetes (k3s) with Traefik, cert-manager for HTTPS, Redis and the site. It went wrong in many small ways first:
- Wrong machine, twice.
bash infra/bootstrap.shran in Cloud Shell (promptszhang3035 [ ~ ]$). The second time it got as far assudoand stopped withThe "no new privileges" flag is set: Cloud Shell is a container with no root. Nothing was changed there. - A syntax error at line 77,
unexpected EOF while looking for matching '"'. The cause was an apostrophe in an error message:: "${ACME_EMAIL:?... Let's Encrypt ...}". Bash read the'inside the parameter expansion as the start of a quoted string and ran off the end of the file. Fixed by rewording, and CI now runsbash -non every shell script so this cannot reach the server again. kubectl waitran too early. k3s installed, but the node had not registered yet, andkubectl wait --for=condition=Ready node --allfails when there are no nodes. Fixed with a loop that first waits for a node to exist.- cert-manager timed out:
Timeout: request did not complete within requested timeout. The 1 GB node was swamped right after install. Added retries. Unable to connect to the server: net/http: TLS handshake timeout. The Kubernetes API itself stopped answering: memory pressure. cert-manager (three more pods) was removed; Traefik's built-in ACME client took over HTTPS.- Pasting joined two lines into
bash: infra/bootstrap.shcd: No such file or directory. Paste one command at a time. - It looked frozen. The script was silently waiting for the API. Every wait now prints
Waiting for .... - Still no API after a reboot (
Timed out waiting for the Kubernetes API). The half-installed cert-manager had left k3s broken, sosudo /usr/local/bin/k3s-uninstall.shand reinstall. Could not get lock /var/lib/dpkg/lock-frontend ... held by process (unattended-upgr): Ubuntu was installing security updates in the background. apt now runs with-o DPkg::Lock::Timeout=900, so it waits instead of failing.- k3s finally came up and created the namespace, Redis and the site, but Traefik's
Middlewareresources failed withno matches for kind "Middleware": Traefik's CRDs were not installed yet. The script now waits for them.
The server stops answering (13:21 to 13:50)
I asked Claude to connect to the server directly. It could not: its sandbox and the link to my Mac both block outbound connections to the VM's IP, and a GitHub Actions workflow that would run commands on the server over SSH was refused by its safety rules. Then I could not get in either: SSH itself hung. The VM had run out of memory. Recovery from Cloud Shell:
az vm restart -g shiqi -n shiqi-1
The decision: Docker Compose (13:50 to 13:55)
k3s plus Traefik plus the system pods wants roughly 600 to 800 MB before the site runs. On a 1 GB machine that leaves nothing. The options:
- Docker Compose on the same VM: about 200 MB for the site, Redis and HTTPS. Free, ships today. Chosen.
- Upgrade to a 4 GB VM and keep k3s: about USD 30 a month, so the USD 100 credit lasts about three months.
- Oracle's free Arm machine: enough for k3s and Kafka, but card verification and capacity lottery.
The Kubernetes manifests and bootstrap-k3s.sh stay in infra/k8s/ for a bigger node later. See Kubernetes on a small node.
Bootstrap round 2: Compose (13:55 to 16:03)
The new infra/bootstrap.sh, run on the VM:
cd ~/shiqi.si && git pull && ACME_EMAIL=<email> GITHUB_OWNER=pajarnas bash infra/bootstrap.sh
It adds 2 GB of swap, turns on unattended security upgrades, uninstalls k3s if present, installs docker.io and docker-compose-v2, writes infra/server/.env (image and ACME email), and starts infra/server/compose.yml:
web: the site image from GHCR, read-only filesystem, all capabilities dropped, 256 MB limit, health check on/api/health.redis:redis:8.8.3-alpinewith append-only persistence, 64 MB max memory with LRU eviction, 96 MB container limit.caddy: terminates HTTPS on 80/443, gets and renews the Let's Encrypt certificate by itself, redirectswwwto the bare domain, 128 MB limit.
At 16:03 the site was up at https://shiqi.si. See Docker Compose on a small VM and Automatic HTTPS with Caddy.
What I learned
- Size the tools to the machine. The memory budget should have been checked before choosing Kubernetes. Measure (
free -h,docker stats) instead of assuming. - Know which machine a prompt belongs to.
saigezhang@macis the Mac,saige@shiqi-1is the server,szhang3035 [ ~ ]$is Azure Cloud Shell. Half of the day's errors were the right command on the wrong machine. - Make setup scripts idempotent and chatty. Safe to re-run, and they say what they are waiting for.
- Let CI catch the boring bugs.
bash -nwould have caught the quoting error before it reached the server. - Silence is not progress. When SSH stops answering, the machine is usually out of memory.
Still open
- Automatic deploys are not on yet: the CI deploy job is skipped until the repository has the
DEPLOY_*secrets and theDEPLOY_ENABLEDvariable (step 4 ofdocs/deploy.md). Until then, a new version goes live by runningbash infra/deploy.sh ghcr.io/pajarnas/shiqi.si:lateston the server. - A bigger node (Oracle Arm) for k3s and Kafka.