This isn't a universal deployment tutorial, but a real record of 8cat.life's journey from "wanting to change a blog theme" to an actually accessible site. Keeping the detours we took is so that when we rebuild, migrate, or troubleshoot in the future, we'll know why we made these choices.
What We Wanted to Do
The initial goal was simple: use the Yohaku theme and Mix Space Core to build a personal site for long-term writing.
Content isn't pre-limited to one technical direction. This will cover code, AI, history, philosophy, and questions and ideas still taking shape.
The Final Setup
Here's the current structure:
The diagram omits two deliberately separated concerns: content is always maintained in the Mix Space backend, while infrastructure and source code are managed via Git and CI. This way, writing posts doesn't require rebuilding the image; only frontend changes trigger the release process.
Here's the current structure:
8cat.life: Yohaku frontend, running in a Docker container on a Tencent Cloud Lighthouse server.api.8cat.life: Mix Space Core, reverse-proxied by Caddy to the Core service.- Core dependencies: PostgreSQL, Redis, COS object storage.
- Frontend image: CNB builds the Docker image, pushed to Tencent Cloud's personal image registry TCR.
- Auto-deploy: CNB uses Tencent Cloud's TAT to invoke the automation agent on the Lighthouse server, pulling the specified image and rebuilding the frontend container.
The key here is separating "building the image" from "running the image": CNB handles the build, and the server only pulls and runs the already-built version.
The Path Taken
1. Initially Tried Deploying Directly to EdgeOne Pages
Yohaku is a Next.js SSR project. EdgeOne Pages can recognize Next.js, but during the actual build, Next.js 16's Turbopack/webpack process was forcibly terminated by the platform's memory limit: the logs showed SIGKILL and a memory cap of about 6 GiB.
Even switching the build from Turbopack to webpack didn't help; the build was still terminated. Additionally, EdgeOne's runtime had compatibility issues with the project's middleware/proxy. So we didn't continue hacking the routes on the production branch just to fit the platform.
Trade-off: EdgeOne is great for static sites and lightweight frameworks, but Yohaku's current SSR build size doesn't fit that path. We kept the test branch and switched production to Docker.
2. GitHub Actions Can Build, But Isn't Ideal for the Final Release Pipeline
GitHub Actions successfully built Yohaku, confirming that the source code, submodules, and Next.js build itself have no fundamental issues.
However, after putting the image on GHCR, pulling it from the Tencent Cloud server was slow due to cross-border traffic. And having GitHub Runner SSH directly into the server for releases would bring issues like unfamiliar IP alerts and long-term credential management.
Trade-off: GitHub remains the primary source repository; image building and server releases are handled within the Tencent Cloud ecosystem.
3. CNB, TCR, and TAT for Automated Deployment
We settled on CNB for Docker builds, with artifacts pushed to a private TCR. Images use both an immutable commit SHA tag and latest; the server actually deploys the SHA tag, making it easy to trace "which commit is the site running." Releasing isn't about "having the server recompile code" but about giving it a verified, ready-made image.
This step also hit a few snags:
- When CNB used a domestic npm mirror, some packages weren't synced yet, causing 404s; those packages needed to fall back to the official npm registry.
- The
git: not founderror during Docker builds came from hooks in the install phase; the container build doesn't have a.gitdirectory, so it's harmless noise, not a build failure. - TAT initially used the wrong CVM-style instance ID; Lighthouse servers actually need the
lhins-...instance ID. - TAT CAM permissions, besides
RunCommand, also need permissions to query invocation and task status; missing one would result in "command sent but pipeline still fails."
Finally, after CNB completes the build, it calls TAT to have the server execute: pull the image for the specified SHA, update the compose service, and rebuild the yohaku container.
Content and Theme Configuration
In Mix Space, "pages" are regular CMS content, not Yohaku theme files. So the initial 404s for /about, /about-site, and /message in the footer were straightforward: those pages hadn't been created yet.
Now created:
- About Me:
/about - About This Site:
/about-site - Messages:
/message
Yohaku's homepage and footer visual configuration lives in the theme/shiro JSON under "Code Snippets" in the backend, not in the project root's theme.json. That's where you configure the homepage title, intro, hitokoto, favicon, and footer links.
Images, Favicon, and Email
- Images use Tencent Cloud COS. Core currently uses one set of S3 connection settings, with blog post images and comment images isolated by different object path prefixes; the access key is granted only the minimum permissions needed for that bucket.
- The favicon was migrated from the old site; the frontend provides a 512px original for the browser to scale. If the theme keeps the old path, the frontend gracefully falls back to this high-resolution resource.
- Email subscriptions use Resend. The sending domain is
updates.8cat.life, e.g., sender addressnewsletter@updates.8cat.life. The API Key is stored only in the Core backend configuration, never in the frontend or code repository.
Limitations to Remember
Google Comment Login
Google OAuth browser authorization can complete, but the server also needs to access oauth2.googleapis.com to exchange the authorization code for a token. The Core container on the Tencent Cloud server times out connecting to that address on port 443, so it ultimately shows invalid_code.
This isn't a missing Google callback URL. To enable Google login, you'd need to configure an HTTPS proxy for Core that can reliably reach Google; for now, keeping the working GitHub login is more appropriate.
import { createServer } from 'node:http'
const server = createServer() // [!code highlight]
server.listen(3000)
console.log('ready')