Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Build context for the container image: only what the Astro build needs.
.git
.github
.vscode
.astro
node_modules
dist
deploy
Dockerfile
.dockerignore
README.md
LICENSE
.env
.env.*
*.log
.DS_Store
66 changes: 66 additions & 0 deletions .github/workflows/image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Container image

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read
packages: write

concurrency:
group: image-${{ github.ref }}
cancel-in-progress: true

env:
IMAGE: ghcr.io/mrguato/gitops-notes

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: docker/setup-qemu-action@v3

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
# Every build is tagged sha-<short commit>; main also moves latest.
tags: |
type=sha
type=raw,value=latest,enable={{is_default_branch}}

# Pull requests build both architectures to prove the Dockerfile still
# works, but nothing is pushed until the change lands on main.
# The cluster mixes Raspberry Pi 4 (arm64) and x86_64 nodes.
# CLUSTER_SITE_URL is an optional repository variable for the public
# hostname the cluster serves; the default matches deploy/k8s.
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
ASTRO_BASE=/
ASTRO_SITE=${{ vars.CLUSTER_SITE_URL || 'https://notes.deleontech.net' }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Keep the manifest list to the two platform images so the package
# page and the kubelet see only amd64 and arm64 entries.
provenance: false
28 changes: 28 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1

# Stage 1: build the static site.
# --platform=$BUILDPLATFORM runs the build once on the runner's native
# architecture. The output is plain HTML, CSS and JS, so it is copied into
# the amd64 and arm64 images alike instead of being rebuilt under emulation.
FROM --platform=$BUILDPLATFORM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
# The cluster serves the site at the root of its own hostname.
ARG ASTRO_BASE=/
ARG ASTRO_SITE=https://notes.deleontech.net
ENV ASTRO_BASE=$ASTRO_BASE \
ASTRO_SITE=$ASTRO_SITE
RUN npm run build

# Stage 2: serve it with nginx as a non-root user on port 8080.
# nginx-unprivileged runs as uid 101, keeps its pid file and temp
# directories under /tmp and listens on 8080, so no root is needed anywhere.
FROM nginxinc/nginx-unprivileged:1.31-alpine
LABEL org.opencontainers.image.source="https://github.com/MrGuato/gitops-notes"
LABEL org.opencontainers.image.description="GitOps Notes, a static Astro site served by nginx"
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
USER 101
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,21 @@ flowchart LR
A[[local editor]] --> B[[git commit]]
B --> C[[git push]]
C --> D[[ci build]]
D --> E[[deploy to host]]
D --> E[[github pages]]
D --> H[[container image]]
H --> I[[flux on k3s]]
I --> J[[cloudflare tunnel]]
A --> F[[src/content/posts]]
F --> D
D --> G[[dist output]]
```

## Where it runs

The site has two deployment targets built from the same source.

GitHub Pages is the original home. A push to `main` runs `.github/workflows/deploy.yml`, which builds with the default base path `/gitops-notes` and publishes to https://mrguato.github.io/gitops-notes. Nothing about that path changed.

The k3s homelab is the second target. The same push runs `.github/workflows/image.yml`, which builds the site with `ASTRO_BASE=/`, packages it with nginx in a multi-arch container image, and pushes it to `ghcr.io/mrguato/gitops-notes`. The manifests under `deploy/k8s` run that image in the cluster behind a Cloudflare Tunnel. The setup steps for the cluster repo are in [deploy/k8s/README.md](deploy/k8s/README.md).

The cluster hostname is public on purpose. Every other app in the cluster sits behind Cloudflare Access, but a blog is meant to be read by anyone, so there is no login in front of it. The protection comes from the layers around it instead. The container serves static files only, runs nginx as a non-root user on a read-only filesystem with every capability dropped, and sends a strict Content-Security-Policy. A NetworkPolicy lets only the tunnel pods reach nginx and lets nginx reach nothing but cluster DNS, so a compromised web pod cannot touch other namespaces. Cloudflare caches the whole site at the edge, runs Bot Fight Mode and rate limits the hostname, so a traffic spike is absorbed before it reaches the home connection.
18 changes: 16 additions & 2 deletions astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
// @ts-check
import { defineConfig } from "astro/config";

// Two deployment targets share this config.
// GitHub Pages serves the site under /gitops-notes, which is the default.
// The container build passes ASTRO_BASE=/ and ASTRO_SITE=<cluster hostname>
// so the same source produces root-relative links for the cluster.
const site = process.env.ASTRO_SITE || "https://mrguato.github.io";
const base = process.env.ASTRO_BASE || "/gitops-notes";

// https://astro.build/config
export default defineConfig({
site: "https://mrguato.github.io",
base: "/gitops-notes",
site,
base,
markdown: {
shikiConfig: {
themes: { light: "github-light", dark: "github-dark" },
defaultColor: false,
},
},
vite: {
build: {
// Never inline small scripts or stylesheets. The container sends a
// Content-Security-Policy that forbids inline scripts.
assetsInlineLimit: 0,
},
},
});
190 changes: 190 additions & 0 deletions deploy/k8s/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# Running the site in the k3s cluster

These manifests serve the GitOps Notes container in the pi-cluster behind a
Cloudflare Tunnel on a public hostname. The folder mirrors the pi-cluster
layout so it can be copied straight into that repo:

```text
deploy/k8s/apps/
base/gitops-notes/ -> pi-cluster apps/base/gitops-notes/
namespace.yaml namespace with Pod Security Admission "restricted"
deployment.yaml 2 nginx replicas, non-root, read-only root fs, probes, limits
service.yaml ClusterIP on 8080
networkpolicy.yaml nginx: ingress from cloudflared only, egress to DNS only
pdb.yaml PodDisruptionBudgets for nginx and cloudflared
cloudflared.yaml per-namespace tunnel, reads Secret cloudflared-tunnel
staging/gitops-notes/ -> pi-cluster apps/staging/gitops-notes/
kustomization.yaml base plus the encrypted secret
secret.yaml.example shape of the tunnel secret, no real values
```

The hostname used throughout is `notes.deleontech.net`. To use another one,
change it in `secret.yaml`, set the `CLUSTER_SITE_URL` repository variable in
the gitops-notes repo to the matching `https://` URL, and rebuild the image.

## Why the hostname is public

Every other app in the cluster sits behind Cloudflare Access with email OTP.
A blog is meant to be read by anyone, so this hostname gets no Access policy.
The protection comes from the layers around the pod instead:

- The container serves static files only, answers GET and HEAD, runs nginx as
uid 101 on a read-only root filesystem with every capability dropped, and
sends a Content-Security-Policy with no inline scripts.
- The namespace enforces the restricted Pod Security profile.
- The NetworkPolicy lets only the cloudflared pods reach nginx and lets nginx
reach nothing but cluster DNS. The cloudflared pods may reach the Cloudflare
edge, DNS and nginx, and nothing in private address space.
- Cloudflare caches the whole site at the edge, runs Bot Fight Mode and rate
limits the hostname, so a burst of traffic is absorbed before it reaches the
home connection.

## Steps in the pi-cluster repo

### 1. Publish the image

Merge the gitops-notes change to `main`. The "Container image" workflow builds
`linux/amd64` and `linux/arm64` and pushes `ghcr.io/mrguato/gitops-notes` with
two tags: `sha-<short commit>` and `latest`.

The cluster pulls without an image pull secret, so the package must be public.
On GitHub open your profile, Packages, `gitops-notes`, Package settings, and
change the visibility to public. This is a one-time step.

Copy the `sha-` tag from the workflow run (the metadata step prints it) into
`apps/base/gitops-notes/deployment.yaml` in place of `sha-0000000`.

### 2. Create the tunnel

On a machine with `cloudflared` and access to the Cloudflare account:

```bash
cloudflared tunnel login
```

```bash
cloudflared tunnel create gitops-notes
```

Note the tunnel ID it prints. The credentials file lands in
`~/.cloudflared/<TUNNEL_ID>.json`.

### 3. Route the public hostname, with no Access application

```bash
cloudflared tunnel route dns gitops-notes notes.deleontech.net
```

This creates a proxied CNAME to `<TUNNEL_ID>.cfargotunnel.com`. Do not create
an Access application for it. In Zero Trust, open Access, Applications, and
confirm nothing there matches `notes.deleontech.net`, including wildcard
applications for the zone.

### 4. Copy the manifests and encrypt the secret

From the pi-cluster checkout, with `GITOPS_NOTES` pointing at this repo:

```bash
mkdir -p apps/base/gitops-notes apps/staging/gitops-notes
```

```bash
cp "$GITOPS_NOTES"/deploy/k8s/apps/base/gitops-notes/*.yaml apps/base/gitops-notes/
```

```bash
cp "$GITOPS_NOTES"/deploy/k8s/apps/staging/gitops-notes/kustomization.yaml apps/staging/gitops-notes/
```

```bash
cp "$GITOPS_NOTES"/deploy/k8s/apps/staging/gitops-notes/secret.yaml.example apps/staging/gitops-notes/secret.yaml
```

Edit `apps/staging/gitops-notes/secret.yaml`: paste the contents of
`~/.cloudflared/<TUNNEL_ID>.json` into `credentials.json`, and put the tunnel
ID into `config.yaml`. Then encrypt it in place with the cluster's SOPS config,
which lives under `clusters/staging/` and not at the repo root:

```bash
sops --config clusters/staging/.sops.yaml --encrypt --in-place apps/staging/gitops-notes/secret.yaml
```

Check that both values are encrypted before committing:

```bash
grep -c 'ENC\[' apps/staging/gitops-notes/secret.yaml
```

The count should be 2. Delete `~/.cloudflared/<TUNNEL_ID>.json` once the
encrypted file is committed, or move it somewhere outside any git checkout.

### 5. Add the Flux Kustomization

The Flux Kustomization `apps` in `clusters/staging/apps.yaml` already
reconciles `./apps/staging` with SOPS decryption, and Flux picks up any
subfolder that has its own `kustomization.yaml`. Commit the two new folders
and push:

```bash
git add apps/base/gitops-notes apps/staging/gitops-notes
```

```bash
git commit -m "feat: gitops-notes site behind a public cloudflare tunnel"
```

Then reconcile and check:

```bash
flux reconcile kustomization apps --with-source
```

```bash
kubectl -n gitops-notes get pods,svc,networkpolicy,pdb
```

```bash
kubectl -n gitops-notes logs deploy/cloudflared | grep -i registered
```

```bash
curl -sI https://notes.deleontech.net | grep -i -e '^HTTP' -e content-security-policy -e cf-cache-status
```

### 6. Cloudflare dashboard

All of this is on the zone, under the `deleontech.net` site.

Caching. Open Caching, Cache Rules, Create rule. Name it, match
`Hostname equals notes.deleontech.net`, set Cache eligibility to
"Eligible for cache", Edge TTL to "Use cache-control header if present,
bypass cache if not", and Browser TTL to "Respect origin TTL". Deploy. The
origin sends a one-year `Cache-Control` for `/_astro/` and five minutes for
pages, so the edge serves almost every request itself. A second
`curl -sI` of the hostname should show `cf-cache-status: HIT`.

Bot Fight Mode. Open Security, Bots, and turn Bot Fight Mode on. On the newer
dashboard it is under Security, Settings, Bot traffic.

Rate limiting. Open Security, WAF, Rate limiting rules, Create rule. Match
`Hostname equals notes.deleontech.net`, count by IP, allow 100 requests per 10
seconds, and block for 10 seconds when exceeded. Those are the limits the free
plan allows for its one rule. On the newer dashboard it is under Security,
Security rules, Create rule, Rate limiting rule.

Also confirm Always Use HTTPS is on under SSL/TLS, Edge Certificates. The
origin already sends Strict-Transport-Security.

## Updating the site

Merge a new post. The image workflow prints a new `sha-` tag. Change the tag
in `apps/base/gitops-notes/deployment.yaml`, commit, and Flux rolls it out
one replica at a time with `maxUnavailable: 0`, so the site stays up. Cached
pages refresh within five minutes, or purge the hostname under Caching,
Configuration, Purge Cache.

## Checking the manifests locally

```bash
kubectl apply --dry-run=client -k deploy/k8s/apps/base/gitops-notes
```
Loading
Loading