Writing

Moving a Wiki from Cloudflare to Proxmox

How the journey went moving a hosting platform to a self-hosted solution.

3 min readproxmoxcloudflaremigration

My wiki used to live on Cloudflare. Nine articles, eighteen tags, a D1 database doing the storage. This week I moved it onto a Proxmox box in the house: one unprivileged container, Docker inside, SQLite on disk.

The repo has a helper script for exactly this. Creates the container, installs Docker, clones the app, writes a config, builds. One command.

It died at the clone.

```

fatal: could not read Username for 'https://github.com'

```

The repo is private. The container had no credentials, and pct exec gives git no terminal to prompt on, so instead of hanging forever it just quit. Small mercy.

I had two options. Copy an SSH private key into the container so it could fetch for itself, or clone on the Proxmox host and push the files in. The first is faster. It also means a key with repo access sits inside an app container that listens on port 3000, and I'd rather not. So the host did the cloning and shipped the tree over.

Side effect: the update command the script prints at the end, git pull inside the container, is broken for the same reason. Nobody would find that out until the first time they tried to update.

The key I couldn't read

Before any of that, the host needed to authenticate to GitHub. Deploy key, read only, five minutes.

Proxmox already had an SSH key sitting in /root/.ssh, so the plan was to read the public half off the console and paste it into GitHub. It was 4096-bit RSA. 716 characters of base64, displayed as pixels in a browser terminal, at a font size where capital O and zero are genuinely indistinguishable.

I didn't trust my reading, so I checked it. Chopped the key into eight blocks, computed a weighted character sum for each block on the host, computed the same thing in the browser, compared the numbers. Six matched. Two didn't.

Read those two again. Still wrong. Read them a third time at double zoom. Still wrong.

At that point I stopped and generated an ed25519 key instead, which has a 68-character public half. Short enough that I could verify it against the host's raw byte output, character by character, and know it was right before submitting anything. It also meant the deploy key was now scoped to one repo rather than being the same key the host uses to reach every other machine on the network, which I should have done from the start.

Everything imported. Half of it was wrong.

Exporting from D1 needed wrangler, wrangler needs Node, and the Mac had neither Node nor Homebrew. Rather than install a toolchain to move one file I went at Cloudflare's API directly, asked for a no-schema export, and pulled the result down through the browser. 53 kilobytes, 98 lines, zero CREATE TABLE statements.

Before importing I listed the tables in the new database and compared them against the dump, because the dump touched a table the import script doesn't mention. It existed. Good.

The import ran in a transaction. Foreign key check came back clean. Counts matched the dump exactly: three users, nine articles, eighteen tags, forty-one tag links, eight badges.

Then I opened the badges page and it was a list of words.

```

edit_note First Note

bolt Founding Member

folder_open Organizer

```

Material Symbols names. The old Cloudflare app pulled in that icon font and turned them into pictures. The new app ships Inter Tight and nothing else, and it stores badge icons as emoji. Both versions have a column called icon. Both take a string. Nothing in the schema disagrees.

The import script wipes the seeded badges and inserts the old rows whole, which quietly swapped eight working emoji for eight words that render as words. No error. Nothing failed. It's correct at every layer you'd normally check, and wrong on the screen.

Fix was eight rows and one column, matched by slug, read out of the app's own source so I wasn't retyping emoji. Took about a minute once I knew.

Leftovers

The script defaults to local-lvm for storage and grabs the next free container ID. On this host that meant the system SSD and an ID that already had old backups filed against it in PBS. Both are one environment variable each. Both would have been irritating to undo later.

The container's on DHCP and its address is baked into APP_URL, so that wants a reservation before the lease moves.

And the thing I keep coming back to: I checked the table names before importing and felt quite pleased about it. I didn't check a single value. The schema check caught nothing because nothing was wrong with the schema.