Bootstrapping This Portfolio
This post walks through bootstrapping a portfolio site with the same shape as this one: an Astro site with a blog and a projects section, developed inside a reproducible devenv environment, and deployed automatically to Codeberg Pages through a Woodpecker CI pipeline. Nothing here is exotic — the point is having the whole path from an empty directory to a self-deploying site in one place.
The dev environment
Everything starts with a devenv.nix. It gives you a reproducible shell with Node.js, without polluting your system or relying on whatever version happens to be installed globally:
{ pkgs, ... }:
{
name = "portfolio";
languages.javascript = {
enable = true;
npm.enable = true;
npm.install.enable = true;
};
packages = with pkgs; [
jq
just
];
}
npm.install.enable = true runs npm install automatically whenever package.json changes and you re-enter the shell — one less step to remember. From here on, everything runs through devenv shell -- <command>.
Scaffolding Astro
devenv shell -- npm create astro@latest -- --template basics
Then add the integrations this site actually uses:
devenv shell -- npx astro add react tailwind mdx sitemap
astro add does the annoying part for you: installs the package and wires it into astro.config.mjs. After adding math support on top (see below), the config ends up looking like this:
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwindcss from '@tailwindcss/vite';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
export default defineConfig({
site: 'https://yourdomain.example',
markdown: {
remarkPlugins: [remarkMath],
rehypePlugins: [rehypeKatex],
},
integrations: [react(), mdx(), sitemap()],
vite: { plugins: [tailwindcss()] },
});
site matters here — @astrojs/sitemap needs an absolute URL to generate correct entries, and it’s a one-time setting: every future page or post gets picked up automatically on the next build, no manual sitemap maintenance.
Content as data: Astro Content Collections
Rather than hardcoding blog posts or project cards into .astro files, both live as Markdown with typed frontmatter, validated at build time:
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
}),
});
const projects = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/projects' }),
schema: z.object({
title: z.string(),
description: z.string(),
tags: z.array(z.string()),
link: z.string().url(),
}),
});
export const collections = { blog, projects };
A blog post is then just a file:
---
title: "Hello, World"
description: "First post."
pubDate: 2026-01-01
---
Body content here.
And a page lists them with getCollection('blog'), sorted by date — no CMS, no database, just files that TypeScript and Zod keep honest.
Dark mode without a UI framework
Tailwind v4 moved configuration into CSS. A custom variant driven by a .dark class (instead of only prefers-color-scheme) is one line:
/* src/styles/global.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
The theme itself is a tiny inline script in the <head>, applied before first paint to avoid a flash of the wrong theme, plus a toggle button:
<script is:inline>
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
<script is:inline>
themeToggle.addEventListener('click', () => {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
</script>
Math and prettier prose
If your blog needs LaTeX-style math ($...$ inline, or a fenced $$ block for display mode), remark-math + rehype-katex handle the parsing and rendering — already shown in the astro.config.mjs above. One gotcha: a $$formula$$ written on a single line parses as inline math; display math needs the delimiters on their own lines:
$$
\mathrm{Hom}_D(F(X), Y) \;\cong\; \mathrm{Hom}_C(X, G(Y))
$$
Import KaTeX’s stylesheet on the post page (not globally, to avoid shipping its weight on pages with no math):
import 'katex/dist/katex.min.css';
Separately, Tailwind’s Preflight strips default styling from every HTML element — so raw Markdown output (headings, lists, code) renders with zero visual hierarchy unless you opt in. The @tailwindcss/typography plugin fixes that:
@import "tailwindcss";
@plugin "@tailwindcss/typography";
<div class="prose prose-lg max-w-none dark:prose-invert">
<Content />
</div>
One default worth knowing about: Typography decorates inline <code> with literal backtick characters via CSS pseudo-elements. If that’s not the look you want, prose-code:before:content-none prose-code:after:content-none turns it off.
A justfile for the recurring commands
just is a thin task runner — each recipe is documented with a # comment, which doubles as the --list output:
# List available recipes
default:
@just --list
# Install project dependencies
install:
npm install
# Start the dev server in the background
dev:
astro dev --background
# Stop the background dev server
dev-stop:
astro dev stop
# Build the site for production
build:
astro build
Run inside the devenv shell: devenv shell -- just dev.
Shipping it: Codeberg Pages via Woodpecker CI
Codeberg Pages serves static files from a branch named pages. For a repository literally named pages, that serves at the root of your <user>.codeberg.page subdomain; for any other repo, it serves under a path. The trick is that pages isn’t something you maintain by hand — it’s generated fresh on every deploy.
A .woodpecker.yml at the repo root does the whole job:
when:
- event: [push, manual]
branch: main
steps:
- name: build
image: node:22
commands:
- npm ci
- npx astro build
- name: deploy
image: alpine/git
environment:
CODEBERG_TOKEN:
from_secret: codeberg_token
commands:
- git config --global user.name "Woodpecker CI"
- git config --global user.email "ci@codeberg.org"
- cd dist
- git init
- git checkout -b pages
- git add -A
- git commit -m "Deploy $CI_COMMIT_SHA"
- git push --force https://USER:$${CODEBERG_TOKEN}@codeberg.org/USER/REPO.git pages:pages
The deploy step turns dist/ into a brand-new git repo on every run and force-pushes it as pages — that branch only ever holds generated output, so rewriting its history each time is expected, not a bug. $${CODEBERG_TOKEN} (doubled $) matters: Woodpecker preprocesses from_secret values in the compiled pipeline, and the extra $ escapes that so the shell — not Woodpecker — does the substitution at runtime.
For the site to actually pick up new pages pushes promptly, add a Forgejo-type webhook in the repo’s settings pointing at the site’s own production URL, filtered to the pages branch — otherwise Codeberg Pages relies on its own polling interval.
Secrets, without hardcoding them anywhere
Some things a CI pipeline needs (API tokens) shouldn’t live in the repo, but also shouldn’t require you to paste raw values into a terminal every time. secretspec declares what a project needs and resolves it from a provider — a local OS keyring for development, CI secret stores in the pipeline:
# secretspec.toml
[project]
name = "portfolio"
revision = "1.0"
[profiles.default]
SOME_TOKEN = { description = "What this is for", required = true }
Setting the value locally:
secretspec set SOME_TOKEN --provider keyring --profile default
And resolving it on demand, with an explicit justification (secretspec requires a --reason for programmatic/agent access by default — a deliberate audit trail, not a bug):
secretspec run --profile default --provider keyring --reason "why you need it" -- your-command
One pitfall: devenv has its own secretspec integration (devenv.yaml’s secretspec.enable) that can wire resolved secrets straight into env.*. That’s convenient, but it validates every declared secret on every shell entry — meaning an unrelated command like just build would suddenly demand a --reason too. If you only need a secret for one occasional task (rotating a token, hitting an external API from a script), it’s simpler to skip that integration entirely and call secretspec run directly only when you actually need it.
What’s left
That covers the whole path: a reproducible dev shell, a typed content model instead of hardcoded markup, a working dark mode, math and readable prose in blog posts, a documented set of task-runner commands, and a CI pipeline that rebuilds and redeploys on every push — with secrets kept out of the repository and out of the shell history. From here it’s mostly content: writing posts, adding projects, and tuning the design.