For a long time, publishing a change to this site went like this: build locally, pack the output into a tarball, stream it to my server over SSH, log in as root, delete the old files, copy the new ones in, and hope I didn’t mistype anything in between. Every typo fix on my CV meant repeating that ritual.

Now I type git push, and about 30 seconds later the change is live.

This post walks through the whole setup from zero:

  1. Building a portfolio and blog with Astro, and why it’s such a good fit for a personal site.
  2. Serving it from your own VPS with nginx, and reaching the server comfortably over SSH.
  3. The old manual deploy, so you can see exactly what we’re replacing.
  4. Automating it with GitHub Actions, so every push to main deploys on its own.

Everything here is taken from what runs this site. The content schema and the deploy workflow are the exact files; the Astro components are shortened so the important parts stand out.

Part 1: Why Astro for a portfolio?

My day job involves mobile apps and, sometimes, Spring Boot services. Spring Boot is great for what it’s built for: APIs, business logic, databases, long-running processes. But think about what a personal site actually needs:

None of that changes per request. There’s no user login, no database, no business logic. Running a JVM process 24/7 to serve text that changes a few times a month means paying for memory, keeping a runtime patched, and handling a service that can crash, all for pages that could just be files.

Astro turns your site into plain HTML files at build time. You write components and Markdown, run one build command, and get a folder of static files. Any web server can serve them. Nothing runs on the server except the web server itself.

A few things make Astro especially nice for this:

To make “lightweight” concrete, here’s this site after npm run build:

Dependencies in package.json 1 (astro)
Config file 5 lines, effectively empty
Build time under 1 second
Size of the whole built site about 96 KB
Files in the output 7
Separate JavaScript bundles 0
Homepage HTML about 31 KB

That’s the entire site: a CV page, a blog index, blog posts, CSS and a favicon. Pages this small load almost instantly, even on a bad mobile connection.

Part 2: Building the site

Create the project

You need Node.js 22.12 or newer. Then:

npm create astro@latest -- --template minimal
cd your-project
npm run dev

The minimal template gives you almost nothing, which is the point. Open http://localhost:4321 and you have a running site with live reload.

The four commands you’ll use:

Command What it does
npm run dev Local dev server at localhost:4321 with live reload
npm run build Builds the production site into dist/
npm run preview Serves the built dist/ locally, to check before deploying
npm run astro … Other Astro CLI commands

This is the whole package.json of this site:

{
  "name": "sulaimankhan-site",
  "type": "module",
  "version": "0.0.1",
  "engines": { "node": ">=22.12.0" },
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "astro": "astro"
  },
  "dependencies": {
    "astro": "^7.2.6"
  }
}

Project structure

Here’s how this site is organised:

/
├── .github/workflows/deploy.yml   ← the auto-deploy (Part 4)
├── public/                        ← copied as-is: favicon, images
├── src/
│   ├── components/
│   │   ├── Nav.astro
│   │   └── Footer.astro
│   ├── layouts/
│   │   └── BaseLayout.astro       ← shared <head>, nav, footer
│   ├── pages/                     ← every file here becomes a URL
│   │   ├── index.astro            → /
│   │   └── blog/
│   │       ├── index.astro        → /blog
│   │       └── [...slug].astro    → /blog/<post-name>
│   ├── content/blog/*.md          ← blog posts
│   ├── content.config.ts          ← schema for posts
│   └── styles/global.css
├── astro.config.mjs
└── package.json

The key idea is file-based routing: every .astro or .md file in src/pages/ becomes a page, and its path becomes the URL.

A shared layout

Every page needs the same <head>, navigation and footer. In Astro, that’s a layout component with a <slot /> where the page content goes:

---
// src/layouts/BaseLayout.astro
import Nav from '../components/Nav.astro';
import Footer from '../components/Footer.astro';
import '../styles/global.css';

const { title, description = 'Software engineer. Portfolio and blog.' } = Astro.props;
---

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta name="description" content={description} />
  <title>{title}</title>
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
  <Nav />
  <slot />
  <Footer />
</body>
</html>

The part between the --- fences is the component script. It runs at build time, on your machine, never in the visitor’s browser.

The portfolio page: content as data

My CV page keeps its content as plain arrays in the component script and renders them with .map(). Updating my CV means editing a string, not fighting with HTML:

---
// src/pages/index.astro (shortened)
import BaseLayout from '../layouts/BaseLayout.astro';

const experience = [
  {
    role: 'Software Engineer',
    company: 'Example GmbH',
    period: '05/2023 — Present',
    bullets: [
      'Build and maintain the company\'s mobile apps',
      'Own the team\'s CI/CD runners',
    ],
    tech: ['Flutter', 'Swift', 'Spring Boot'],
  },
  // …more jobs
];
---

<BaseLayout title="Your Name — Software Engineer">
  <section id="experience">
    <h2>Experience</h2>
    {experience.map(job => (
      <div class="job">
        <h3>{job.role} · {job.company}</h3>
        <p class="period">{job.period}</p>
        <ul>{job.bullets.map(b => <li>{b}</li>)}</ul>
        <div>{job.tech.map(t => <span class="chip">{t}</span>)}</div>
      </div>
    ))}
  </section>
</BaseLayout>

The .map() runs once during the build. The browser receives finished HTML, with no framework and no hydration.

The blog: content collections

Blog posts live in src/content/blog/ as Markdown files. A content collection tells Astro where they are and what every post must contain:

// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    excerpt: z.string(),
    date: z.coerce.date(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

export const collections = { blog };

A post then looks like this. The part between the --- lines is the frontmatter, which is checked against that schema:

---
title: "My first post"
excerpt: "One sentence shown in the post list."
date: 2026-09-29
tags: ["Astro"]
---

Normal **Markdown** goes here: headings, lists, `code`, code blocks, links.

If a post is missing its title or date, the build fails with a clear error. That sounds strict, but it’s exactly what you want: a broken post never makes it online. (This saved me once, as you’ll see in Part 4.) draft: true keeps a post out of the build until it’s ready.

The blog index loads the collection, hides drafts and sorts by date:

---
// src/pages/blog/index.astro
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';

const posts = (await getCollection('blog', ({ data }) => !data.draft))
  .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
---

<BaseLayout title="Blog">
  {posts.map(post => (
    <a href={`/blog/${post.id}`}>
      <h2>{post.data.title}</h2>
      <p>{post.data.excerpt}</p>
    </a>
  ))}
</BaseLayout>

And one dynamic route generates a page per post. getStaticPaths() tells Astro which pages to build:

---
// src/pages/blog/[...slug].astro
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection, render } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('blog', ({ data }) => !data.draft);
  return posts.map(post => ({ params: { slug: post.id }, props: { post } }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---

<BaseLayout title={post.data.title} description={post.data.excerpt}>
  <article>
    <h1>{post.data.title}</h1>
    <Content />
  </article>
</BaseLayout>

Writing a new post is now: create src/content/blog/my-new-post.md, and it appears at /blog/my-new-post and in the post list.

Build it

npm run build

The output lands in dist/: HTML files, a couple of CSS files, and whatever you put in public/. That folder is your entire website. Deploying means getting it onto a server.

Part 3: Hosting on your own VPS

You could put dist/ on a static host, and that’s a perfectly good option. I wanted my own small VPS (mine is at Hetzner): full control, my own domain, and a place to run other things later. A static site needs very little from it: a web server and a folder.

nginx serves the folder

This site is served by nginx on Ubuntu, from /var/www/sulaimankhan.de. A minimal server block looks like this:

# /etc/nginx/sites-available/example.com
server {
    listen 80;
    server_name example.com www.example.com;

    root /var/www/example.com;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

Enable it and reload nginx:

ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

For HTTPS, Certbot with the nginx plugin gets a free Let’s Encrypt certificate and updates this config for you (certbot --nginx -d example.com -d www.example.com).

Astro builds each page as a folder containing an index.html (for example blog/my-post/index.html). With try_files $uri $uri/, nginx serves the clean URL /blog/my-post/ without any extra configuration.

Comfortable SSH with a config file

Typing ssh root@<ip-address> every time gets old fast, and it means keeping an IP address in your head, or worse, in your README. An entry in ~/.ssh/config on your computer gives the server a short name:

# ~/.ssh/config
Host hetzner
    HostName <your-server-ip>
    User root
    IdentityFile ~/.ssh/id_ed25519

If you don’t have a key yet, create one and copy it to the server once:

ssh-keygen -t ed25519 -C "you@laptop"
ssh-copy-id -i ~/.ssh/id_ed25519.pub root@<your-server-ip>

From then on, it’s just:

ssh hetzner

No password, no IP to remember. Every tool that uses SSH understands the alias too, including scp, rsync and git.

Part 4: From manual deploys to push-to-deploy

The old way

Here’s the process I used to follow for every single change, straight from my old README:

# 1. Build locally
npm install
npm run build

# 2. Upload dist/ to a temporary folder on the server
ssh hetzner 'rm -rf /tmp/newsite && mkdir -p /tmp/newsite'
tar -C dist -cf - . | ssh hetzner 'tar -C /tmp/newsite -xf -'

# 3. Log in and replace the live site
ssh hetzner
mkdir -p /var/www/sulaimankhan.de
find /var/www/sulaimankhan.de -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
cp -r /tmp/newsite/. /var/www/sulaimankhan.de/

It worked, but it hurt:

The goal

Step 1: A dedicated deploy user on the server

GitHub Actions needs to log in to the server. It should not log in as root. If the secret ever leaked, whoever had it would own the whole server. Instead, create a user whose only power is writing to the website folder.

Log in (ssh hetzner) and run:

apt-get update && apt-get install -y rsync
adduser --disabled-password --gecos "" deploy
mkdir -p /var/www/sulaimankhan.de
chown -R deploy:deploy /var/www/sulaimankhan.de
chmod 755 /var/www/sulaimankhan.de

What each part does:

Step 2: An SSH key just for deploys

On your own computer, create a separate key pair used only by CI:

ssh-keygen -t ed25519 -N "" -C "github-actions-deploy" -f ~/deploy_key
cat ~/deploy_key.pub

-N "" means no passphrase, since CI can’t type one. That’s acceptable because this key can only log in as deploy, which can only write to the website folder.

Back on the server, install the public key for the deploy user:

install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
echo 'ssh-ed25519 AAAA…paste-your-key… github-actions-deploy' >> /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys

SSH is strict here: if .ssh or authorized_keys can be written by anyone other than the owner, it silently ignores the key.

While you’re on the server, note its host key fingerprint. You’ll need it in step 3:

ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

Then test from your computer that the new user works:

ssh -i ~/deploy_key deploy@<your-server-ip> 'echo ok'

It should print ok without asking for a password. Get this working locally before touching GitHub, because debugging SSH through CI logs is miserable.

Step 3: Pin the server’s identity

When CI connects, it should check that it’s really talking to your server and not something pretending to be it. SSH does that with the known_hosts file. Capture your server’s host key:

ssh-keyscan -t ed25519 <your-server-ip> | tee /tmp/kh
ssh-keygen -lf /tmp/kh

The SHA256:… fingerprint printed here must match the one you read on the server in step 2. ssh-keyscan asks whoever answers at that address for their key. Comparing it with the value from inside the server proves nobody was in between. The ssh-keyscan output line (not the one starting with #) is what CI will trust.

Step 4: Store the secrets in GitHub

In your repository, go to Settings → Environments → New environment, name it production, and add four secrets:

Secret Value
DEPLOY_HOST Your server’s IP or hostname
DEPLOY_USER deploy
DEPLOY_SSH_KEY Full contents of ~/deploy_key (the private key), including the BEGIN and END lines
DEPLOY_KNOWN_HOSTS The line from ssh-keyscan

Putting the secrets in an environment rather than at repository level limits them to the deploy job, and lets you add protection rules later, such as requiring approval.

Then delete the local copies. GitHub has the private key, the server has the public key, and nobody needs a third copy:

rm ~/deploy_key ~/deploy_key.pub /tmp/kh

A nice side effect: the server’s address now lives in a secret instead of your README.

Step 5: The workflow

This is the complete .github/workflows/deploy.yml that runs this site:

name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

# Never run two deploys at once; a newer push waits for the running one.
concurrency:
  group: deploy-production
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    environment: production

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Configure SSH
        env:
          DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
          chmod 644 ~/.ssh/known_hosts

      - name: Upload to server
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
        run: |
          rsync -az --delete --delay-updates \
            -e "ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" \
            dist/ "$DEPLOY_USER@$DEPLOY_HOST:/var/www/sulaimankhan.de/"

      - name: Clean up SSH key
        if: always()
        run: rm -f ~/.ssh/deploy_key

Section by section:

Triggers. Every push to main deploys. workflow_dispatch adds a “Run workflow” button in the Actions tab, for redeploying without a new commit.

concurrency. Only one deploy runs at a time. With cancel-in-progress: false, a second push waits instead of killing the first deploy halfway through an upload.

permissions: contents: read. The job only needs to read the code, so that’s all the built-in GitHub token can do.

Install and build. npm ci installs exactly the versions in package-lock.json and fails if the lockfile and package.json disagree. cache: npm speeds up later runs. If the build fails, the job stops here and nothing is uploaded. The live site keeps running the previous version.

Configure SSH. Writes the private key and the pinned host key into place. The secrets are passed as environment variables rather than pasted straight into the script text, which keeps them out of the command itself. The key gets mode 600 because SSH refuses keys other users can read.

Upload. This one command replaces the whole old step 2 and step 3:

rsync flag What it does
-a Archive mode: recursive, keeps permissions and timestamps
-z Compresses data during transfer
--delete Removes files from the server that no longer exist in dist/
--delay-updates Uploads changed files to temporary names and swaps them all in at the end

--delay-updates is what closes the old “broken window”: visitors see the old version until the very end of the transfer, then the new one. And because rsync only sends files that changed, a typo fix uploads a few kilobytes, not the whole site.

The SSH options:

Option Why
-i ~/.ssh/deploy_key Log in with the deploy key
IdentitiesOnly=yes Don’t try any other keys
StrictHostKeyChecking=yes Refuse to connect if the server’s key doesn’t match known_hosts

Mind the trailing slash in dist/: it means “the contents of dist”, not “a folder called dist inside the web root”.

Clean up. if: always() removes the key file even when an earlier step failed. GitHub’s runners are thrown away after each job anyway, so this is extra caution.

Step 6: Push, and watch it deploy

Commit the workflow and push:

git add .github/workflows/deploy.yml
git commit -m "Deploy automatically with GitHub Actions"
git push

Open the Actions tab to watch the run. For this site, the new version was live about 30 seconds after the push, including checkout, Node setup, install, build and upload.

To check from the outside that the new version is really being served, you can ask for a page that only exists in the new version:

curl -s -o /dev/null -w '%{http_code}\n' https://example.com/blog/my-new-post/

200 means it’s live.

When the build fails, nothing breaks

My very first automated run failed, which turned out to be a good demonstration. I had written a new post directly on GitHub and forgotten its frontmatter. The content schema from Part 2 caught it:

[InvalidContentEntryDataError] blog → automating-ios-code-signing-with-fastlane-match
data does not match collection schema.
  title: Required
  excerpt: Required

The job stopped at Build, the upload never ran, and the live site stayed exactly as it was. I added the missing frontmatter, pushed again, and the next run deployed normally. With the old manual process, the same mistake could have happened halfway through an rm -rf.

Before and after

Old manual deploy Push-to-deploy
What I type ~8 commands across two machines git push
Where it builds My laptop A clean GitHub runner, every time
Who logs in to the server root deploy, which can only write to the website folder
Downtime during deploy Site empty between rm and cp Files swapped in at the end
Broken build Could reach production Stops before upload
What gets uploaded Everything, every time Only changed files
Time to live Several minutes of manual work About 30 seconds, hands-off

Troubleshooting

A few problems I ran into, or that you’re likely to hit:

Permission denied (publickey) in CI. Check the permissions on the server (700 for /home/deploy/.ssh, 600 for authorized_keys, both owned by deploy). Check that you pasted the full private key into DEPLOY_SSH_KEY, including the first and last lines.

Host key verification failed. The DEPLOY_KNOWN_HOSTS secret doesn’t match the server. This also happens legitimately if you reinstall the server. Repeat step 3 and update the secret.

rsync: command not found. rsync isn’t installed on the server. apt-get install -y rsync.

chown: cannot access '…/authorized_keys' while setting up. The echo … >> authorized_keys line before it is what creates the file, and it didn’t run. The usual reasons are that the deploy user or .ssh folder doesn’t exist yet, or the quotes around the pasted key are unbalanced (the shell shows a > prompt waiting for more input). When a sequence of setup commands fails midway, look at the first error, not the last one.

The site is deployed, but nginx shows 403 Forbidden. nginx can’t read the files. Check that directories are 755 and files 644.

Exec format error when running npm run build locally. This happened to me because the project lives on an NTFS drive shared with another operating system. npm creates symlinks in node_modules/.bin/, and on that drive they don’t work as Linux executables. Running the CLI through Node works around it (node node_modules/astro/bin/astro.mjs build), but the real fix is keeping projects on a Linux filesystem. It never happens in CI.

Next steps

A few improvements worth making once this is running:

Wrapping up

A personal site doesn’t need an application server. Astro gives you components, Markdown and type-checked content while you write, and a folder of plain HTML when you ship. A small VPS with nginx serves that folder easily, and a single GitHub Actions workflow with one rsync command turns deploying into git push.

The whole setup took an evening. Every change since, including publishing this post, has been a push.