Migrating to Astro: A Modern Blogging Setup with Dev Container
A complete guide on migrating a personal blog from Hexo/Hugo to Astro. Learn how to isolate your development environment using VS Code Dev Containers, and automate deployments to GitHub Pages via GitHub Actions.
Hi! Today, I will show you how to leverage a modern Dev Container setup to seamlessly develop and deploy your Astro blog.
A personal blog usually starts simple: write Markdown files, run a static site generator, and publish the generated HTML. Over time, however, the surrounding tooling becomes the real challenge.
In 2026, I decided to rebuild my blog workflow around a more modern stack:
- Astro as the static site framework
- Dev Container as the isolated development environment
- GitHub Actions as the automated deployment pipeline
The goal is not only to create a new blog, but also to create a reproducible engineering workflow: anyone can clone the repository, open it in VS Code, start the Dev Container, and immediately get the same development environment.

Astro is a modern web framework designed for content-driven websites, especially blogs and documentation sites. Compared with traditional static site generators, Astro provides a better balance between simplicity and extensibility:
- Content-first architecture: Markdown and Markdown-based content remain first-class citizens. Blog posts can be managed as structured content collections with front matter, tags, metadata, and custom fields.
- Modern frontend ecosystem: Astro works naturally with modern JavaScript and TypeScript tooling. When advanced UI components are needed, frameworks such as React, Vue, or Svelte can be integrated without converting the whole website into a client-heavy application.
- Excellent performance by default: Astro generates static HTML by default and only ships JavaScript when it is actually needed. For a personal blog, this means faster loading speed and a simpler deployment model.
- Strong customization capability
Themes provide a quick starting point, while the underlying Astro project structure still allows full control over layouts, components, routing, and build behavior.
After comparing Hexo, Hugo, and newer frameworks, Astro provides a more future-proof foundation for a personal blog that may gradually evolve into a richer website.

A good development environment should be part of the project, not a hidden configuration on a personal computer.
A Dev Container is a containerized development environment defined by configuration files inside the project repository (commonly, .devcontainer/devcontainer.json). Instead of installing languages, dependencies, and tools directly on the host machine, developers open the project inside a pre-configured Docker container managed by VS Code - The idea is similar to “infrastructure as code”: the development environment itself becomes reproducible and version-controlled.
Previously, I built and previewed Hugo projects inside WSL. While WSL solved many Linux compatibility problems, the environment still accumulated dependencies and configuration over time.
Dev Container however, addresses this problem by containerizing the development environment.:
- The host machine only needs VSCode and Docker.
- Node.js, Bun, dependencies, and CLI tools live inside the container.
- Every contributor gets the same environment.
- The setup can be recreated at any time from configuration files.
Automation turns a manual build process into a pipeline.

GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform built directly into GitHub. It allows you to automate your build, test, and deployment workflows via YAML configuration files, triggering tasks automatically based on events like code pushes or pull requests.
Before adopting this workflow, publishing a blog meant running a build command locally and manually uploading files or pushing a compiled dist folder to a remote repository. This approach is prone to environment disparities and accidental deployment errors.
Integrating GitHub Actions into the mix solves these issues entirely:
- Build isolation: The compilation environment is completely isolated within GitHub Runners, eliminating the need for any physical machines.
- Hands-off publishing: You don’t need to manually build or sync your static files. A simple git push on your workspace branch triggers the pipeline to build and publish your site automatically.
- Secure credential management: Sensitive tokens required to push to your public GitHub Pages repository are securely managed via encrypted repository secrets.
By automating the pipeline, your focus shifts entirely back to where it matters most: writing content in Markdown, letting the automation handle the rest.
So, in the following sections, I will show you how to build an Astro blog step by step.
I used to compile and preview my Hugo blog inside the Windows Subsystem for Linux (WSL), but over time, I found that this cluttered my system files. Besides, I was getting really tired of constantly manually configuring development environments.
So, now in 2026, I will revamp my environment with VSCode and Dev Container. My developing environment is that:
flowchart LR subgraph Windows["Windows 11"] V["VSCode"] end
subgraph Ubuntu["Ubuntu Server 26.04 LTS"] subgraph Docker["Docker Engine"] D["Dev Container"] end end
V <--> DThe Dev Container provides all the necessary tools and utilities, while VS Code automates the environment scaffolding and communication.
Update /etc/docker/daemon.json with proxy server address:
{ "proxies": { "http-proxy": "http://${YOUR_PROXY_ADDRESS}", "https-proxy": "http://${YOUR_PROXY_ADDRESS}", "no-proxy": "localhost,127.0.0.1,.local,internal.com,192.168.0.0/16,10.0.0.0/8" }, "mtu": 1450}NOTE:
- Reduce
mtuto1450: If you use proxy, the size of payload may exceed MTU of 1500. - Replace
${YOUR_PROXY_ADDRESS}, such as127.0.0.1:7890, according to your intranet settings.
Browse https://astro.build/themes/, and choose whatever you want.
But today, I will showcase my favorite theme: Chirping Astro
create file in code repo .devcontainer/devcontainer.json:
{ "name": "Chirping Astro", "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm", "customizations": { "vscode": { "extensions": [ "astro-build.astro-vscode", "astrojs.astro-build", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "bradlc.vscode-tailwindcss" ] } }, "forwardPorts": [4321], "portsAttributes": { "4321": { "label": "Astro Dev Server", "onAutoForward": "notify" } }, "postCreateCommand": "curl -fsSL https://bun.sh/install | bash && export BUN_INSTALL=\"$HOME/.bun\" && export PATH=\"$BUN_INSTALL/bin:$PATH\" && bun install"}Note:
- Dev Container Base Image: mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm
forwardPortsandportsAttributesare set according to the code base- use
postCreateCommandto getbuncommand for package management
In VSCode project page, press Ctrl + Shift + P, click Dev Container: Reopen Folder in SSH to start building Dev Container.
Once the Dev Container finishes building, a fresh CLI prompt will appear, leaving you with a ready-to-go development environment:
node ➜ /workspaces/chirping-astro (main) $update package.json by adding --host 0.0.0.0, namely:
{ "scripts": { "dev": "astro dev --host 0.0.0.0", "preview": "astro preview --host 0.0.0.0", "serve": "bun run build && bun preview --host 0.0.0.0" // ...... }}Manually designate a slug in the front matter for your each of your blog posts:
## <!-- src/content/posts/en/MigratingAstroWithDevContainer.md -->
title: "Migrating to Astro: A Modern Blogging Setup with Dev Container"slug: "migrating-to-astro-modern-blog-with-dev-container"
tags: ['Misc']
pubDate: 2026-06-16
math: falsemermaid: true
draft: false
## description: "A complete guide on migrating a personal blog from Hexo/Hugo to Astro. Learn how to isolate your development environment using VS Code Dev Containers, and automate deployments to GitHub Pages via GitHub Actions."Note: slug is the canonical name of the blog post, and thus a crucial part of permalink
Add redirect entries to astro.config.mjs in your repo:
export default defineConfig({ /** * Redirects from old blogs - HTTP 301 Moved Permanently */ redirects: { '/page/1/': { status: 301, destination: '/', }, '/2022/05/hello-world/': { status: 301, destination: '/posts/hello-world/', }, // ...... }, // ......});Since the dependencies are all set up after Dev Container initialization, we just need to run:
bun run devThen it will display the preview links:
$ astro dev --host 0.0.0.0[astro] `markdown.remarkPlugins`, `markdown.rehypePlugins`, and `markdown.remarkRehype` are deprecated. Pass them to `unified({...})` from `@astrojs/markdown-remark` directly instead.[vite] connected.14:41:48 [types] Generated 0ms[vite] connected.14:41:49 [content] Syncing content14:41:49 [content] Astro config changed14:41:49 [content] Clearing content store14:41:51 [content] Synced content astro v6.4.3 ready in 4317 ms┃ Local http://localhost:4321/┃ Network http://172.17.0.2:4321/14:41:51 watching for file changes...To preview the rendered blog, simply Ctrl + left-click the non-localhost link(s), e.g., http://172.17.0.2:4321/. Thanks to VS Code’s port forwarding, this maps the container’s port directly to your local machine.
🎉 Congratulations, we have the new blog end-to-end tested.
Next, we need to polish the blogs gradually before publishing them online.
In the second part, we design an elegant and automated workflow.
- Use a separate code base for self-customized blog infrastructure codes and Markdown posts
- Use CI to automatically build and publish blog online
Create a new private repo, and git push, and edit private repo secrets and variables accordingly
| Name | Type | Description |
|---|---|---|
PUBLIC_GITHUB_HANDLE | Variables | your GitHub username, e.g., username |
SITE_URL | Variables | your GitHub Pages URL, e.g., https://username.github.io |
MY_GITHUB_PAGES_REPO_HANDLE | Variables | your GitHub Pages URL, e.g., PUBLIC_GITHUB_HANDLE/PUBLIC_GITHUB_HANDLE.github.io |
MY_GITHUB_PAGES_REPO_ACCESS_TOKEN | Secrets | access token to push to MY_GITHUB_PAGES_REPO_HANDLE |
NOTE:
MY_GITHUB_PAGES_REPO_ACCESS_TOKENis generated from 👉 https://github.com/settings/personal-access-tokens- Remember to create a flag file named
public/.nojekyllin your code base, to ensure that folders prefixed with an underscore (_) are not ignored by GitHub Pages.
Update the .github/workflows/deploy.yml file:
name: Deploy to GitHub Pages
on: push: branches: - my-main paths: - 'src/**' - 'public/**' - 'astro.config.mjs' - 'tsconfig.json' - 'package.json' - 'bun.lock' - '.github/workflows/deploy.yml' workflow_dispatch:
permissions: contents: read pages: write id-token: write
concurrency: group: pages cancel-in-progress: false
env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
jobs: build: name: Build runs-on: ubuntu-latest # Bind the build to the same `github-pages` environment as the deploy # job. This is what makes `vars.*` and `secrets.*` defined under # _Settings → Environments → github-pages_ resolvable here at build # time. Without it, only repository-level vars/secrets are visible. environment: github-pages steps: - name: Checkout uses: actions/checkout@v6
- name: Setup Bun uses: oven-sh/setup-bun@v2 with: # Keep CI Bun pinned so `--frozen-lockfile` is deterministic. # Update this intentionally when you want to adopt a newer Bun. bun-version: 1.3.13
- name: Install dependencies run: bun install --frozen-lockfile
- name: Build with Astro # All values below are read from repository Variables # (Settings → Secrets and variables → Actions → Variables). # Any unset variable expands to an empty string, which the # theme treats as "feature disabled" — so every var is optional # and you only set the ones you actually use. env: # --- Site identity --- # Falls back to the canonical github.io origin for the current # owner so a brand-new fork builds successfully with zero # configuration. Override by setting `vars.SITE_URL` in the UI. SITE_URL: ${{ vars.SITE_URL || format('https://{0}.github.io', github.repository_owner) }} # Sub-path for GitHub Pages. Hard-coded fallback to the repo # name keeps the build correct even before the variable is # configured. Override by setting `vars.BASE_PATH` in the UI. BASE_PATH: ${{ vars.BASE_PATH }} # --- Author / social handles (all optional) --- PUBLIC_GITHUB_HANDLE: ${{ vars.PUBLIC_GITHUB_HANDLE || github.repository_owner }} PUBLIC_GITHUB_REPO: ${{ vars.PUBLIC_GITHUB_REPO }} PUBLIC_TWITTER_HANDLE: ${{ vars.PUBLIC_TWITTER_HANDLE }} PUBLIC_LINKEDIN_HANDLE: ${{ vars.PUBLIC_LINKEDIN_HANDLE }} PUBLIC_CONTACT_EMAIL: ${{ vars.PUBLIC_CONTACT_EMAIL }} # --- Cloudflare Web Analytics --- CLOUDFLARE_WEB_ANALYTICS_ENABLED: ${{ vars.CLOUDFLARE_WEB_ANALYTICS_ENABLED }} CLOUDFLARE_WEB_ANALYTICS_TOKEN: ${{ vars.CLOUDFLARE_WEB_ANALYTICS_TOKEN }} # --- Giscus (all optional) --- PUBLIC_GISCUS_ENABLED: ${{ vars.PUBLIC_GISCUS_ENABLED }} PUBLIC_GISCUS_REPO: ${{ vars.PUBLIC_GISCUS_REPO }} PUBLIC_GISCUS_REPO_ID: ${{ vars.PUBLIC_GISCUS_REPO_ID }} PUBLIC_GISCUS_CATEGORY: ${{ vars.PUBLIC_GISCUS_CATEGORY }} PUBLIC_GISCUS_CATEGORY_ID: ${{ vars.PUBLIC_GISCUS_CATEGORY_ID }} run: bun run build
- name: Upload Build Artifact uses: actions/upload-artifact@v4 with: name: astro-dist-files path: ./dist include-hidden-files: true retention-days: 1
deploy: name: Deploy needs: build runs-on: ubuntu-latest environment: name: github-pages steps: # 1. Download the artifact generated in the previous Build step # This official Action automatically downloads and extracts the artifact. # By default, the extracted content corresponds exactly to all files inside ./dist - name: Download Build Artifact uses: actions/download-artifact@v4 with: name: astro-dist-files path: ./dist
# 2. Deploy to the remote GitHub Pages repository with a clean, single-commit history - name: Push to Target Remote Repo env: TARGET_REPO: ${{ vars.MY_GITHUB_PAGES_REPO_HANDLE }} PERSONAL_TOKEN: ${{ secrets.MY_GITHUB_PAGES_REPO_ACCESS_TOKEN }} COMMIT_PREFIX: '[Deployment](${{ github.ref_name }})' COMMIT_SHA: '${{ github.sha }}' COMMIT_MSG_FULL: '${{ github.event.head_commit.message }}' run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" cd dist rm -rf .git git init git branch -m main git add . COMMIT_MSG_FIRST=$(echo "$COMMIT_MSG_FULL" | head -n 1) COMMIT_MSG="$COMMIT_PREFIX: $COMMIT_MSG_FIRST ($COMMIT_SHA)" git commit -m "$COMMIT_MSG" git config http.extraheader "Authorization: Basic $(echo -n "x-access-token:$PERSONAL_TOKEN" | base64 -w 0)" git push --force https://github.com/${TARGET_REPO}.git mainNOTE:
- We use two main branches, and the default one is
my-main:- branch
mainis used track upstream Astro blog framework - branch
my-mainis used for GitHub Actions to automatically build and push to GitHub Pages
- branch
From now on, every time you update blog in your my-main branch, it will automatically update your blog website
🎉🎉 Congratulations, we have a brand new Astro blog deployed. 🎉🎉