Skip to content

Portfolio Automation Pipeline

A step-by-step guide to building a fully automated portfolio pipeline: markdown editor → static site → Docker container → GitHub. Edit locally, deploy automatically.

Architecture

Markdown Editor (Obsidian)


VitePress (static site generator)


Docker + Nginx (containerized serving)


GitHub (version control + remote backup)

Step 1: Initialize the VitePress Project

bash
mkdir portfolio && cd portfolio
npm init -y
npm install -D vitepress

Add build scripts to package.json:

json
{
  "scripts": {
    "docs:dev": "vitepress dev docs",
    "docs:build": "vitepress build docs"
  }
}

Create your first page at docs/index.md and the config at docs/.vitepress/config.mts.

Step 2: Set Up Dynamic Sidebars

Instead of hardcoding every page in the config, auto-discover them from the filesystem. This is the key trick — create a .md file in any folder and it automatically appears in the sidebar.

In docs/.vitepress/config.mts:

ts
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'

function discoverPages(dir: string) {
  const docsDir = join(process.cwd(), 'docs')
  const fullDir = join(docsDir, dir)
  const items = []

  const files = readdirSync(fullDir).filter(f => f.endsWith('.md')).sort()
  for (const file of files) {
    const slug = file.replace(/\.md$/, '')
    const content = readFileSync(join(fullDir, file), 'utf-8')
    const match = content.match(/^#\s+(.+)$/m)
    const text = match ? match[1] : slug.replace(/-/g, ' ')
    items.push({ text, link: `/${dir}/${slug}` })
  }
  return items
}

export default defineConfig({
  themeConfig: {
    nav: [
      { text: 'Home', link: '/' },
      { text: 'Blog', link: '/blog/first-post' }
    ],
    sidebar: {
      '/blog/': [{ text: 'Blog', items: discoverPages('blog') }]
    }
  }
})

How it works: discoverPages() scans a folder, reads each .md file, extracts the first # Heading as the display title, and builds the sidebar array. Add a new file → it shows up on next build.

Step 3: Containerize with Docker

Multi-stage Dockerfile — build in Node, serve with Nginx:

dockerfile
# Stage 1: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY docs/ ./docs/
RUN npx vitepress build docs

# Stage 2: Serve
FROM nginx:alpine
COPY --from=builder /app/docs/.vitepress/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Step 4: Configure Nginx

The Nginx config handles three things: SPA routing (clean URLs), security headers, and asset caching.

nginx
server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    # SPA fallback — clean URLs without .html
    location / {
        try_files $uri $uri.html $uri/ /index.html;
    }

    # Cache static assets for 1 year
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Security headers
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Gzip text-based assets
    gzip on;
    gzip_types text/plain text/css application/json application/javascript image/svg+xml;

    # Block hidden files
    location ~ /\. {
        deny all;
    }
}

Build and run:

bash
docker build -t portfolio .
docker run -d --name portfolio -p 8080:80 --restart unless-stopped portfolio

Step 5: Automate Deployments

A bash script that detects changes, commits, pushes to GitHub, and rebuilds Docker. Runs on a schedule.

bash
#!/usr/bin/env bash
set -euo pipefail
REPO="/path/to/portfolio"
CONTAINER="portfolio"
cd "$REPO"

# Skip if clean
if git diff --quiet && git diff --cached --quiet && \
   [ -z "$(git ls-files --others --exclude-standard)" ]; then
    exit 0
fi

# Cooldown guard (skip if last commit < 10 min ago, unless --force)
if [ "${1:-}" != "--force" ]; then
  LAST=$(git log -1 --format=%ct 2>/dev/null || echo 0)
  if [ $(($(date +%s) - LAST)) -lt 600 ]; then
    echo "Too soon. Use --force to override."
    exit 0
  fi
fi

# Commit, push, rebuild
git add -A
git commit -m "docs: auto-update — $(date '+%Y-%m-%d %H:%M')"
git push origin main
docker build -t "$CONTAINER" .
docker stop "$CONTAINER" 2>/dev/null || true
docker rm "$CONTAINER" 2>/dev/null || true
docker run -d --name "$CONTAINER" -p 8080:80 --restart unless-stopped "$CONTAINER"

# Verify
sleep 2
CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/)
[ "$CODE" = "200" ] && echo "OK: deployed" || echo "WARN: HTTP $CODE"

Schedule with cron (every 10 hours):

0 */10 * * * /path/to/portfolio-autodeploy.sh

For manual deploys, create a shell alias:

bash
alias deploynow='bash /path/to/portfolio-autodeploy.sh --force'

Open the docs/ folder as an Obsidian vault. Add .obsidian/ to .gitignore so personal settings stay local.

gitignore
.obsidian/
docs/.obsidian/

Now you can:

  • Create pages — Right-click a folder in Obsidian → New note → start with # Title
  • Edit content — Type in Obsidian, auto-save handles the rest
  • Deploy — Run deploynow or wait for the next cron cycle

Key Decisions

DecisionWhy
Multi-stage DockerBuild image has Node (~300 MB), final image is Nginx Alpine (~5 MB)
Dynamic sidebarZero-config page creation. No editing config.mts to add a page
Cooldown guardPrevents commit spam during active editing sessions
--force flagManual deploys bypass the cooldown for instant updates
.obsidian/ in gitignorePersonal editor config stays local, not pushed to public repo
SPA fallback in NginxClean URLs (/blog/post not /blog/post.html) without a Node server