Web Development/4 min read
How this blog is built, and how I got it back after a reset
How this blog runs on a self-hosted Next.js frontend and Strapi CMS on Dokploy, and what I learned rebuilding it: persistent volumes, build-time vs runtime environment variables, and keeping API tokens on the server.
This blog is two apps on my own server: a Next.js frontend at blog.mbalhakim.dev and a Strapi CMS. Both run in Docker, managed by Dokploy. A while ago I reset Dokploy, every environment was gone, and I'd forgotten how I'd set any of it up. Rebuilding it taught me more than building it the first time.
The setup
| Backend | Frontend | |
|---|---|---|
| App | Strapi 5 | Next.js 16 |
| Build | Dockerfile | Dockerfile (standalone output) |
| Stores data | yes (two volumes) | no |
Both are Dokploy Applications that build from their GitHub repos with a Dockerfile. Dokploy's Traefik proxy handles the domains and Let's Encrypt certificates.
I write articles in the Strapi admin panel. The frontend fetches them through Strapi's REST API with a read-only token. The visitor's browser never talks to Strapi directly.
Lesson 1: if it's not on a volume, it's gone
A container's filesystem is thrown away on every redeploy. For Strapi, two things must survive: the database and the uploaded images. Both get a named volume in Dokploy:
| Volume | Mount path |
|---|---|
blog-strapi-db | /opt/app/db |
blog-strapi-uploads | /opt/app/public/uploads |
While rebuilding I found that my old compose file mounted uploads at /app/public/uploads, but the app lives in /opt/app. The mount pointed at a folder nobody used, so uploads were never persisted at all. If you set up volumes, check the path inside the container, not just that a volume exists.
The SQLite database file goes on its own volume via DATABASE_FILENAME=/opt/app/db/data.db. For a personal blog, SQLite on a volume is plenty, and much simpler than running a separate Postgres.
Lesson 2: Strapi needs a build, and to know it's behind a proxy
Two small fixes made the backend work in production:
- The Dockerfile had
npm run buildcommented out.strapi startneeds the compiled app and admin panel, so the build has to run in the image. - Behind Dokploy's HTTPS proxy, Strapi has to know its public URL and trust the proxy headers, or admin login fails because it refuses to send secure cookies over what it thinks is plain HTTP:
url: env('PUBLIC_URL', ''),
proxy: { koa: env.bool('IS_PROXIED', true) },Lesson 3: NEXT_PUBLIC_ variables are baked in at build time
This one cost me the most time. The frontend read the Strapi URL from NEXT_PUBLIC_STRAPI_API_URL. Next.js inlines every NEXT_PUBLIC_* variable into the JavaScript during next build. Setting it as a normal runtime variable in Dokploy does nothing: the built code already contains whatever the value was at build time, which in my case was an empty string.
That caused two failures:
- The build failed. Next.js prerenders the home page at build time, so it tried to fetch
/api/categorieswith no host and crashed withFailed to parse URL. - Then the site was empty. Once the build passed, every page asked itself for articles instead of Strapi, and showed "No articles yet".
I fixed it in three steps:
- Render the data pages on request (
export const dynamic = "force-dynamic"), so the Docker build never needs Strapi. A down or unreachable backend can't break a deploy anymore. Fetches still cache for 60 seconds. - Read the URL and token at runtime from plain
STRAPI_API_URLandSTRAPI_API_TOKENvariables. Without theNEXT_PUBLIC_prefix, Next.js leavesprocess.envalone, so changing them only needs a restart. - Add a 10-second timeout to every Strapi request, so a slow backend fails fast instead of hanging the page.
Lesson 4: keep the token on the server
The search page used to call Strapi from the browser, which meant the API token was shipped in the public JavaScript. Even read-only, that's a token anyone could copy.
I moved search to a server component: the browser asks /search?q=…, the Next.js server queries Strapi with the token, and only HTML comes back. Now no Strapi call happens in the browser at all, and the token never leaves the server.
Lesson 5: write the setup down
The real root cause of all this was that I didn't remember my own setup. So now there's a setup document with every Dokploy setting, environment variable and volume. A secrets file of generated keys lives in my password manager. Strapi's APP_KEYS, API_TOKEN_SALT, ADMIN_JWT_SECRET, TRANSFER_TOKEN_SALT, JWT_SECRET and ENCRYPTION_KEY are all just random values, and generating a fresh set takes one line:
node -e "const r=()=>require('crypto').randomBytes(32).toString('base64');console.log('APP_KEYS='+[r(),r(),r(),r()].join(','));for(const k of ['API_TOKEN_SALT','ADMIN_JWT_SECRET','TRANSFER_TOKEN_SALT','JWT_SECRET','ENCRYPTION_KEY'])console.log(k+'='+r())"The redesign
While I was at it, I redesigned the frontend to match my portfolio: the same grey palette, Geist and Geist Mono typography, mono eyebrow labels, the mb monogram as favicon, a theme toggle that remembers your choice without a flash on load, and generated link-preview images for every article. The article list became simple rows, and articles render with the Tailwind typography plugin mapped to the site's colours.
Takeaways
- Volumes are the only state. Check the mount path inside the container.
NEXT_PUBLIC_means build time. Use plain variables for anything the server reads at runtime.- Don't let the build depend on other services. Render dynamic data on request.
- Secrets belong on the server. If a token is in your client bundle, it's public.
- Document your infrastructure. Future you will have forgotten everything.
Blog / Keep reading
Related articles.
Decentralized Web/5 min read
SilentEcho, a diary the server can't read
An end-to-end encrypted journal with Flutter, Go and a web portal.
Web Development/4 min read
Building a real-time "guess 80% of the average" game
A Socket.io multiplayer game inspired by Alice in Borderland's Balance Scale.