My portfolio with Astro and GitHub Actions, from idea to pipeline
Starting point
My VPS so far only had an unused, freshly installed WordPress, not a finished site. I wanted a real portfolio that itself shows how I build and run a platform: static, without a database, served from a container on my own VPS behind Traefik.
Decision
- Astro instead of WordPress: The site consists of Markdown and TypeScript files. There is no database, no plugins, and nothing I have to patch all the time.
- Astro instead of Gatsby: Astro needs no GraphQL and was quicker to get started with. Gatsby would have worked too.
- Bilingual from the start: German at
/, English at/en/. - Everything in one repository: Content, configuration, Dockerfile and pipeline live together in Git.
Implementation: the site
- Posts live in a separate folder per language. The same file name means the same page, so the language switcher always leads to the translation.
- Icons from Lucide and Simple Icons are written into the pages as inline SVG at build time. The site loads no third-party files for them and needs no tracking.
- There is a light and a dark mode and five selectable accent colours. I redrew my logo as a vector graphic from a hand drawing.
- Almost the whole site is plain, static HTML with no framework JavaScript. Only the theme and accent-colour switcher is a single small Preact component, loaded with
client:idle. That’s Astro’s islands architecture: interactivity is hydrated selectively, not the whole page. - View transitions give smooth page changes without a full reload. The island itself survives navigation (
transition:persist), so it doesn’t have to be rebuilt on every page change. - Links are prefetched in the background on hover, which makes navigation noticeably faster.
- Two scripts save me work:
npm run new-postcreates a post in German and English as a draft.npm run checkverifies translations, icons and leftover TODOs, and it runs automatically before every build. - I developed the setup together with Claude as an assistant. Decisions and review are mine, drafts of code and text came from the assistant.
Implementation: the container
I first planned a dedicated container: a two-stage Dockerfile building the site with Node and serving it with nginx-unprivileged on port 8080, plus a new Compose service with its own Traefik labels. Cross-checking against the real server configuration showed that was unnecessary: nginx-odabas, the container that serves WordPress today, already terminates its own TLS (Traefik only passes the connection through unencrypted, “passthrough”), listens internally on port 8081, and mounts its conf.d and certificates from the host. A Docker bind mount fully overrides the target folder inside the image at startup, so my image doesn’t need its own nginx configuration at all — it just needs to contain the built Astro files. For the switch, only the image: field of the existing nginx-odabas service changes to my image, plus a one-time edit to a single line in its (still server-side) configuration: proxy_pass http://wordpress-odabas:80; becomes root /usr/share/nginx/html;. No new container, no new service, no new Traefik labels — but very much a new image, every time I publish something. wordpress-odabas is then just stopped, not removed.
Implementation: Git and GitHub Actions
The repository is private on GitHub. For commits I use GitHub’s anonymous noreply address and have pushes with my private address blocked. That way it will not show up in the history of a later public repository either.
The build-and-deploy workflow starts on every push to main:
- build builds the Docker image and uploads it to the GitHub Container Registry (
ghcr.io), once aslatestand once tagged with the commit. - deploy connects to the VPS over SSH and specifically restarts the
nginx-odabascontainer (docker compose pull nginx-odabas && docker compose up -d --force-recreate nginx-odabas).
The first run ended red because the deploy step had no credentials for the server yet. The image was built anyway, it appears under Packages in GitHub. Instead of commenting the step out, I added a switch:
deploy:
needs: build
if: ${{ vars.DEPLOY_ENABLED == 'true' }}
runs-on: ubuntu-latest
As long as the variable DEPLOY_ENABLED is not set to true, the step is skipped. The second run went green and took 39 seconds.
Takeaway
- I switch pipelines on in stages: build first, then rollout. A switch beats commented-out code, because it stays visible in the repository.
- A pre-check before the build catches content errors before they go online.
- Small commits with English messages (
Add …,Fix …) keep the history readable. - Cross-checking against the real server configuration showed that odabas.ch doesn’t need a new container at all, but very much still needs the Docker image:
nginx-odabastakes over serving it itself by having itsimage:field switch to my built image. The new Compose service from above isn’t needed anymore, but the image is — it ends up being exactly what runs on the server. - Server access was set up, a temporary test path replaced the old
webtestentrypoint, which no longer exists, and shortly after came the actual switch ofodabas.ch— without noticeable downtime. How that went in detail is the next post.