On this page
This is not meant to be a coding tutorial. It is a record of how I built and launched my own site.
I want to remember what I originally asked for, how I used Codex to turn the idea into a working blog, and what I did afterward with the VPS, domain, HTTPS, Google Search, and ongoing maintenance. If I ever need to rebuild the server, move the site, or launch something similar, this is the note I want to come back to.
I did not want another generic blog template
The original idea was simple: I wanted a place of my own for long-form writing about game development, software engineering, product design, and everyday life.
The requirements became more specific as the project moved forward:
- The site would be called Notes by Wei.
- Every article should be readable in both Chinese and English, rather than translating only the navigation.
- Posts should be written in Markdown, with proper support for code, images, video, tables, and quotes.
- Content should use two-level categories such as “Engineering / Unreal Development.”
- I needed an admin area for writing posts, uploading covers, and managing categories.
- The home page should feel personal and work well on mobile, desktop, and in dark mode.
- The finished site had to run on my own VPS and use my own domain.
- Updating the application must not erase the database or media, and backups had to exist.
- Google should be able to discover the posts, with the option to try AdSense later.
By the end, this had become a small bilingual publishing system rather than a single blog page.
How the site itself was made
I did not hand-code the whole application from scratch. I described the site, page behavior, and changes I wanted to Codex, then used it to implement and iterate on the project.
The final application uses React, TypeScript, vinext, and Markdown. Codex helped implement the bilingual pages, post and category management, dark mode, code highlighting, article outlines, media handling, and both VPS and Cloudflare runtimes. My part was mainly defining what I wanted, reviewing the actual result, pointing out what felt wrong, and deciding what to change next.
The code lives on GitHub, with Git recording each round of changes. The first substantial article was my UE4 UI input-routing analysis. Its Chinese and English source text and images also remain under content/ in the repository.
The source code is there whenever I need the implementation details. The more interesting part of this record is how the project went from “working locally” to running as a site I can maintain.
Why I ended up using my own VPS
The project initially ran in a Sites/Cloudflare environment, using D1 for data and R2 for images. I eventually decided that I wanted the production site under my own control, so I asked Codex to add a VPS runtime as well:
- Node.js runs the application.
- SQLite stores posts and categories.
- Images live in a local server directory.
- Caddy handles the domain, HTTPS, and reverse proxy.
- systemd handles startup, restart, and scheduled backups.
The original Cloudflare runtime still exists, but the production site now runs on the VPS.
The main local commands are:
npm run dev:vps
npm run build:vps
The second command creates the deployable dist/standalone/ directory.
How I handled the VPS
Start with a clean server
The server is a Debian 12 x86_64 VPS with the public IP 65.49.208.223. It is not a large machine, so the goal was to keep the setup small, predictable, and easy to understand.
The repository includes deploy/bootstrap-server.sh for preparing a fresh server. It installs Node.js, Caddy, SQLite, rsync, UFW, and the other basic tools, then creates a dedicated weiming system user for the blog.
Because the VPS has limited memory, the script also adds 1.5 GiB of swap. That gives package upgrades and short traffic spikes a little more room before the machine runs out of memory.
I uploaded and ran the bootstrap script as follows:
export BLOG_VPS_IP="65.49.208.223"
scp deploy/bootstrap-server.sh root@"$BLOG_VPS_IP":/root/
ssh root@"$BLOG_VPS_IP" 'bash /root/bootstrap-server.sh'
The firewall exposes only three ports:
- 22 for SSH;
- 80 for HTTP;
- 443 for HTTPS.
The Node.js process listens only on 127.0.0.1:3000, so it is not directly reachable from the internet.
Keep application releases separate from data
The important server directories look like this:
/opt/weiming-blog/
├── releases/<timestamp>/
└── current -> releases/current-version
/var/lib/weiming-blog/
├── blog.sqlite
└── media/
/var/backups/weiming-blog/
/opt contains application releases. The database and images that must survive deployments live under /var/lib.
That separation means switching the current symlink does not touch published content. If a release is broken, I can also point the symlink back to the previous directory.
Publish a new release
I first install locked dependencies and build on the Mac:
npm ci
npm run build:vps
I then create a timestamped release and upload dist/standalone/:
export BLOG_VPS_IP="65.49.208.223"
export BLOG_RELEASE="$(date -u +%Y%m%dT%H%M%SZ)"
ssh root@"$BLOG_VPS_IP" \
"install -d -m 0755 /opt/weiming-blog/releases/$BLOG_RELEASE"
rsync -az dist/standalone/ \
root@"$BLOG_VPS_IP":"/opt/weiming-blog/releases/$BLOG_RELEASE/"
ssh root@"$BLOG_VPS_IP" \
"ln -sfn /opt/weiming-blog/releases/$BLOG_RELEASE /opt/weiming-blog/current && systemctl restart weiming-blog"
Afterward, I check the service and its recent logs:
ssh root@65.49.208.223 \
'systemctl status weiming-blog --no-pager'
ssh root@65.49.208.223 \
'journalctl -u weiming-blog -n 100 --no-pager'
Keep the site running
weiming-blog.service supervises the application. It starts automatically after a reboot and retries if the process crashes.
The service does not run as root. It uses the dedicated weiming user, which can write to /var/lib/weiming-blog but has little reason to write anywhere else. This limits the damage an application problem could cause.
Put a password in front of the admin area
The public site is open, but /admin and the write APIs must not be.
I enforce this at the Caddy layer with Basic Auth. The password is never stored in plain text. I generate a one-way hash on the server:
caddy hash-password
Only the resulting hash goes into /etc/caddy/Caddyfile.
Whenever I change Caddy, I validate the file before reloading it:
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
There is also a Caddyfile.locked in the repository. If the admin password is not ready yet, that configuration closes the admin and write routes completely, avoiding even a short unprotected window.
Back up the site every day
The database and media are backed up daily:
- SQLite creates a consistent copy using its own
.backupcommand. media/is stored as a compressed archive.- The VPS keeps the most recent 14 days.
I can trigger a backup manually with:
ssh root@65.49.208.223 \
'systemctl start weiming-blog-backup.service'
Backups on the same VPS are not enough for a real disaster. If the entire machine disappears, the backups disappear with it. I still need to download /var/backups/weiming-blog/ periodically or synchronize it to separate object storage.
I used nip.io before attaching the real domain
I did not start by debugging the production domain. The first public test used:
weiming-65-49-208-223.nip.io
nip.io resolves the IP embedded in the hostname. It let me check whether:
- Ports 80 and 443 were reachable.
- Caddy could obtain an HTTPS certificate.
- The Node process and reverse proxy worked.
- The admin password was actually enforced.
- The database and images survived a restart.
This was a useful dividing line. Once the temporary hostname worked, a later problem with the real domain was much more likely to be DNS or domain configuration rather than the application itself.
How I connected notesbywei.com
The production domain is notesbywei.com, with DNS managed through Cloudflare.
The two essential records are:
| Type | Name | Target |
|---|---|---|
| A | @ | 65.49.208.223 |
| CNAME | www | notesbywei.com |
The apex points directly to the VPS, and www follows it. Git does not record whether the Cloudflare proxy toggle was orange-cloud or DNS-only at the time, so that setting should be checked in the Cloudflare dashboard rather than guessed from the repository.
Once DNS had propagated, I:
- Changed the main Caddy hostname to
notesbywei.com. - Added a permanent redirect from
www.notesbywei.comto the apex. - Redirected the old nip.io hostname to the production domain.
- Set the application’s single base URL to
https://notesbywei.com. - Reloaded Caddy so it could obtain the production HTTPS certificate.
Finally, I checked both addresses:
curl -I https://notesbywei.com/
curl -I https://www.notesbywei.com/
The apex now serves the site, while www returns a 301 redirect to it. Visitors and search engines therefore see one consistent production address.
How I handled Google Search
Google Search and AdSense are easy to mix up, but they solve different problems.
Google Search is about discovery and indexing. AdSense is about advertising review and delivery. A site does not need AdSense before it can appear in search.
Prepare the site first
I asked Codex to add the basic pieces search engines expect:
robots.txtthat allows public content and blocks admin/API routes;- a dynamic
sitemap.xmlcontaining the home page, categories, posts, About, and Privacy pages; hreflanglinks between Chinese and English pages;- page-specific titles, descriptions, and canonical URLs;
- structured post data such as publication time, author, and cover image;
- an RSS feed at
feed.xml; - 301 redirects from
wwwand the old hostname to the canonical domain.
The live files are visible here:
- https://notesbywei.com/robots.txt
- https://notesbywei.com/sitemap.xml
- https://notesbywei.com/feed.xml
None of this guarantees indexing. It simply gives Google a clean description of what pages exist, which hostname is canonical, and how the Chinese and English versions relate to each other.
Then use Search Console
Actions inside Search Console do not appear in Git history, so I should use this checklist whenever I need to confirm the setup:
- Open https://search.google.com/search-console.
- Add a Domain property for
notesbywei.com. - Copy the
google-site-verification=...TXT value from Google. - Add that TXT record to the apex in Cloudflare DNS.
- Wait for DNS propagation and click Verify in Search Console.
- Keep the TXT record after verification.
- Submit
sitemap.xmlin the Sitemaps section. - Inspect the home page and the first Chinese and English article URLs.
- If they have not been discovered, use Request indexing once.
- Watch the Pages, Sitemaps, and Crawl stats reports for problems.
I use a Domain property because it covers the apex, www, HTTP, and HTTPS together instead of requiring several separate properties.
Google indexing is rarely immediate. Submitting a sitemap tells Google where the pages are; it does not guarantee inclusion. Waiting days or even weeks for a new site is normal, and repeatedly pressing Request indexing does not make the process noticeably faster.
Official references:
Where AdSense stands
The site has the technical hooks for AdSense, including the script, placements, ads.txt, and bilingual privacy disclosures. That only means it is ready to be connected; it does not mean the site has passed AdSense review.
When I decide to apply, I still need to add notesbywei.com in AdSense, obtain the real ca-pub-... and placement IDs, and store them in the server environment. Those values should never be committed to Git.
If the audience includes regions that require consent, I will also need a Google-certified consent management platform. That work is separate from Search Console and search indexing.
How I update the site now
A normal article release follows this rough sequence:
- Prepare the Chinese Markdown, English Markdown, and images.
- Preview both languages locally.
- Run checks and builds.
- Commit with Git and push to GitHub.
- Build a new VPS release.
- Upload it, switch
current, and restart the service. - Check the home page, both post versions, media, sitemap, and RSS feed.
- Optionally request indexing for an important new article.
Common local commands:
npm ci
npm run dev:vps
npm run lint
npm test
npm run build:vps
Common server commands:
ssh root@65.49.208.223 \
'systemctl status weiming-blog caddy --no-pager'
ssh root@65.49.208.223 \
'journalctl -u weiming-blog -n 100 --no-pager'
ssh root@65.49.208.223 \
'systemctl start weiming-blog-backup.service'
ssh root@65.49.208.223 \
'caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy'
The tools I actually used
Looking back, the toolset was not especially exotic:
- Codex for implementation, troubleshooting, and deployment help;
- Git and GitHub for source and history;
- Node.js and npm for development and builds;
- React, TypeScript, and vinext for the application;
- SQLite for production content data;
- SSH and rsync for VPS access and releases;
- Debian, systemd, and UFW for server operation and security;
- Caddy for HTTPS, reverse proxying, admin authentication, and redirects;
- Cloudflare for DNS;
- Google Search Console for search discovery;
curlanddigfor checking the result.
Looking back
The most important result was not the amount of code. It was completing the whole chain:
I began by defining what I wanted and using Codex to implement it. I then verified it locally, prepared the VPS, tested with a temporary hostname, connected the production domain and HTTPS, prepared search discovery, and established the release and backup routines.
I now control the code, server, domain, database, and source content. Even if I move to another VPS or hosting platform later, the site and its writing are not locked inside a hosted blogging product.
There are still a few things worth checking periodically: whether the Search Console property remains verified, whether the sitemap reports success, whether off-site backups are working, and whether SSH password login and direct root login have been restricted. Once those habits are in place, the site is ready for long-term maintenance.
