Publish Posts Without Redeploying: ISR and a Bind Mount
TL;DR
Rebuilding and restarting a Docker container to publish each markdown post caused short 5xx outages that Google Search Console reported as server errors. Mounting the content folder into the running container and letting Next.js regenerate pages on demand made publishing a plain file copy, with no restart and no errors.
Key takeaways
- A few seconds of downtime per deploy adds up when deploys happen every 15 minutes.
- Google Search Console counts those short outages as server errors on real URLs.
- Mount content into the container read-only and let ISR pick up new files.
- Any in-memory cache of the content must notice when files change, or new posts never appear.
For a while, Google Search Console kept emailing me about "Server error (5xx)" on pages of this site. When I loaded those pages, they worked. The errors were real, though. They just lasted a few seconds at a time.
The cause turned out to be the way new posts were published, and the fix made publishing simpler as well as reliable.
How publishing used to work
The site is a Next.js app in a Docker container on a small VPS, behind a Cloudflare Tunnel. Blog posts are markdown files. A cron job published a new post every 15 minutes, and after writing the file it ran:
docker compose up -d --build portfolio
That rebuilt the image and replaced the running container so the new post would be included. Each time, there were a few seconds where the old container had stopped and the new one was not yet accepting requests. Any visitor or crawler that arrived in that gap got an error from Cloudflare.
A few seconds is easy to dismiss. But at four deploys an hour, and with Googlebot crawling hundreds of pages a day, Google was bound to hit the gap regularly. Search Console reported each hit as a server error on a real URL.
The idea: content is data, not code
Rebuilding the whole application to add one markdown file is backwards. The code had not changed, only the content. The fix was to stop baking content into the image and let the running app read it from disk.
That needed three pieces.
1. Mount the content folder into the container
In docker-compose.yml, the content directory on the host is mounted into the container, read-only:
services:
portfolio:
volumes:
- ./src/content/blog:/app/src/content/blog:ro
Now a new file written on the host is immediately visible inside the running container. Read-only means the web app itself can never change or delete posts.
2. Let pages render on demand
With hundreds or thousands of posts, pre-rendering every page at build time is slow anyway. The post route pre-renders only the newest posts and renders everything else on first request:
export function generateStaticParams() {
return getListedPosts().slice(0, 60).map((p) => ({ slug: p.slug }));
}
export const dynamicParams = true; // render unknown slugs on demand
export const revalidate = 3600; // re-read a post at most once an hour
Pages that list posts, such as the blog index, the sitemap and the RSS feed, use a shorter window so new posts show up quickly:
export const revalidate = 300;
3. Make the content cache notice new files
This is the step that is easy to miss. The function that loads posts cached the parsed result in memory, so it parsed the folder once and never again. In a long-running server, that meant new posts would never appear, mount or no mount.
The cache now keys on the directory's modification time and file count. Adding or removing a file changes the directory's mtime, which invalidates the cache:
let _cache: Post[] | null = null;
let _cacheKey = "";
function loadAll(): Post[] {
const files = fs.readdirSync(BLOG_DIR).filter((f) => /\.mdx?$/.test(f));
const key = `${fs.statSync(BLOG_DIR).mtimeMs}:${files.length}`;
if (_cache && key === _cacheKey) return _cache;
_cache = files.map(parseFile).filter(Boolean).sort(byNewest);
_cacheKey = key;
return _cache;
}
Within a single render, the folder does not change, so each file is still parsed only once.
Publishing now
Publishing a post is now just writing a file into the mounted folder. The cron job's rebuild line is gone:
# No rebuild or restart: the container reads this folder directly
# and serves the new post through ISR.
echo "new post — live via ISR, no rebuild" >> "$LOG"
The container has not been recreated for content since the change. Search Console's server-error count for the site dropped to zero and stayed there.
When you still need a rebuild
Code changes still need a rebuild, and so do new files in public/, because Next.js's standalone output copies that folder into the image at build time. For those, I tag the current image before building so I can roll back in seconds:
docker tag sandeep-portfolio:latest sandeep-portfolio:rollback-<name>
docker compose build portfolio && docker compose up -d portfolio
A failed build leaves the running container untouched, so a typo never takes the site down.
The takeaway
If your deploy process restarts the server to change content, every publish is a small outage. Separate content from code: mount it, let the framework render on demand, and make sure any cache notices when files change. Publishing becomes a file copy, and the error reports stop.
Frequently Asked Questions
What is incremental static regeneration?
ISR is a Next.js feature that serves a cached static page and regenerates it in the background after a set time, or renders a page for the first time on demand. It gives static-site speed without rebuilding the whole site to change content.
Why mount the content folder read-only?
The web server only needs to read posts. Mounting read-only means a bug or compromise in the web app cannot modify or delete content files.
How long until a new post appears?
The post page itself renders on its first request. Listings such as the blog index and sitemap refresh within their revalidate window, which is five minutes on this site.
Sandeep Kumar Chaudhary
Full Stack Software Developer· Nepal's SEO, AEO, GEO & AIO expert and share-market educator. More about me
