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
mkdir portfolio && cd portfolio
npm init -y
npm install -D vitepressAdd build scripts to package.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:
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:
# 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.
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:
docker build -t portfolio .
docker run -d --name portfolio -p 8080:80 --restart unless-stopped portfolioStep 5: Automate Deployments
A bash script that detects changes, commits, pushes to GitHub, and rebuilds Docker. Runs on a schedule.
#!/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.shFor manual deploys, create a shell alias:
alias deploynow='bash /path/to/portfolio-autodeploy.sh --force'Step 6: Link Obsidian
Open the docs/ folder as an Obsidian vault. Add .obsidian/ to .gitignore so personal settings stay local.
.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
deploynowor wait for the next cron cycle
Key Decisions
| Decision | Why |
|---|---|
| Multi-stage Docker | Build image has Node (~300 MB), final image is Nginx Alpine (~5 MB) |
| Dynamic sidebar | Zero-config page creation. No editing config.mts to add a page |
| Cooldown guard | Prevents commit spam during active editing sessions |
--force flag | Manual deploys bypass the cooldown for instant updates |
.obsidian/ in gitignore | Personal editor config stays local, not pushed to public repo |
| SPA fallback in Nginx | Clean URLs (/blog/post not /blog/post.html) without a Node server |