Files
qumo-website/qumo-website-rebuild-plan.md

30 KiB

qumo.io Website Rebuild — Architecture & Setup Plan

From Webflow to self-hosted Astro, behind Caddy, managed with Claude Code + OpenSpec.


1. What we're building

A complete rebuild of qumo.io as an Astro 5 static site, served from an nginx container on the existing VPS, proxied through Caddy, with a self-hosted form handler for the contact page. The current Webflow site stays live as a visual reference until the new site is ready to swap in.

Target stack

Internet → Cloudflare (orange cloud, Full strict)
              │
              ▼
           Caddy (existing, on VPS)
              │
              ├── qumo.io ──────────► qumo-website (nginx:stable-alpine-slim, port 80)
              │                         └── serves /usr/share/nginx/html/ (Astro dist/)
              │
              ├── qumo.io/api/contact ► qumo-form-handler (Go container, port 8080)
              │                         └── validates + sends email via SMTP relay
              │
              └── analytics.qumo.io ──► umami (optional, later)

Pages to build (EN + NL)

Route (EN) Route (NL) Source on current site
/ /nl Homepage — hero, services, stats ticker, approach, CTA
/about /nl/about Mission, values, team, expertise
/contact /nl/contact Contact form + meeting booking link
/privacy /nl/privacy Privacy policy (new — currently missing)

Plus: 404 page, robots.txt, sitemap.xml (auto-generated by Astro), _redirects or Caddy-level redirects.

What is NOT in scope for v1

  • Blog / CMS content
  • Client portal or authenticated pages
  • E-commerce
  • AI product page (exists in SharePoint as draft content — can be added as a later OpenSpec change)

2. Repository structure

The repo lives on Gitea at gitea.qumo.io. The project is a monorepo containing the Astro site, the form handler, and the Docker Compose stack for deployment.

qumo-website/
├── .claude/                     # Claude Code project configuration (committed to git)
│   ├── CLAUDE.md                # Project context — loaded automatically every session
│   ├── settings.json            # Hooks, permissions, env vars (shared with team)
│   ├── settings.local.json      # Personal overrides (gitignored automatically)
│   ├── skills/                  # Auto-generated by `openspec init` + `openspec update`
│   │   └── (openspec skills)    # OpenSpec slash commands (/opsx:propose, etc.)
│   └── agents/                  # Custom subagents (optional, add later if needed)
│
├── .mcp.json                    # MCP server config — Astro docs (committed to git)
│
├── openspec/                    # OpenSpec SDD configuration
│   ├── config.yaml              # Project context + rules
│   ├── specs/                   # Living specifications (grows over time)
│   └── changes/                 # Active change proposals
│       └── archive/             # Completed changes
│
├── website/                     # Astro project root
│   ├── astro.config.mjs
│   ├── tailwind.config.mjs
│   ├── tsconfig.json
│   ├── package.json
│   ├── src/
│   │   ├── layouts/
│   │   │   └── BaseLayout.astro       # HTML shell, <head>, nav, footer
│   │   ├── components/
│   │   │   ├── Nav.astro
│   │   │   ├── Footer.astro
│   │   │   ├── Hero.astro
│   │   │   ├── ServiceCard.astro
│   │   │   ├── StatsTicker.astro
│   │   │   ├── TeamMember.astro
│   │   │   ├── ContactForm.astro
│   │   │   ├── LanguageSwitcher.astro
│   │   │   └── ...
│   │   ├── pages/
│   │   │   ├── index.astro            # EN homepage
│   │   │   ├── about.astro
│   │   │   ├── contact.astro
│   │   │   ├── privacy.astro
│   │   │   ├── 404.astro
│   │   │   └── nl/
│   │   │       ├── index.astro        # NL homepage
│   │   │       ├── about.astro
│   │   │       ├── contact.astro
│   │   │       └── privacy.astro
│   │   ├── content/                   # i18n content strings
│   │   │   ├── en.json
│   │   │   └── nl.json
│   │   ├── styles/
│   │   │   └── global.css             # Tailwind base + Archia font-face
│   │   └── assets/                    # Images, SVGs, fonts (processed by Astro)
│   │       ├── fonts/
│   │       │   └── archia/            # Archia font files (woff2)
│   │       ├── images/
│   │       └── icons/                 # Phosphor Icons (SVG)
│   ├── public/
│   │   ├── robots.txt
│   │   ├── favicon.svg
│   │   └── og-image.png
│   └── Dockerfile                     # Multi-stage: Node build → nginx serve
│
├── form-handler/                # Contact form backend
│   ├── main.go                  # HTTP server: POST /api/contact
│   ├── go.mod
│   ├── go.sum
│   ├── Dockerfile
│   └── README.md
│
├── docker-compose.yml           # Production stack (website + form-handler)
├── .env.example                 # Template for secrets
├── .gitignore
└── README.md

3. Key architectural decisions

3.1 Localization approach

Astro does not have built-in i18n routing, but its file-based routing makes it straightforward. Each locale gets its own page files under pages/nl/. Shared content strings live in JSON files (content/en.json, content/nl.json). A helper function loads the right strings based on the current URL prefix.

Each page imports content like:

---
import { getStrings } from '../content/i18n';
const t = getStrings('nl');  // or 'en'
---
<h1>{t.hero.title}</h1>

<link rel="alternate" hreflang="..."> tags are added in the base layout for SEO. The language switcher component reads the current path and swaps the locale prefix.

3.2 Styling: Tailwind CSS + Archia font

Tailwind is configured with the Qumo brand tokens as custom theme values. No component library — just utility classes. The Archia font is self-hosted (woff2 files in src/assets/fonts/).

Tailwind config extends with:

colors: {
  midnight: '#102022',
  snow: '#F3F3F3',
  'brand-blue': '#5257E4',
  'brand-red': '#F71E3E',
},
fontFamily: {
  archia: ['Archia', 'ui-sans-serif', 'system-ui', 'sans-serif'],
},

The gradient is applied via a CSS class: bg-gradient-to-br from-brand-blue to-brand-red.

3.3 Animations

The current Webflow site uses scroll-triggered animations (fade-ins, slide-ups) and a horizontal stats ticker. In Astro, these are implemented with:

  • Scroll animations: CSS @keyframes + IntersectionObserver in a small inline <script> (no framework needed). Astro's client:visible directive can also lazy-load interactive islands.
  • Stats ticker: A CSS-only infinite horizontal scroll using @keyframes and animation: scroll linear infinite. No JavaScript required.
  • Page transitions: Astro's built-in View Transitions API (transition:animate).

3.4 Contact form

The form HTML lives in the Astro component. On submit, JavaScript POSTs to /api/contact which Caddy routes to the form-handler container.

Form handler (Go, ~100 lines):

  • Accepts POST with JSON body: name, email, company, message
  • Validates required fields, email format
  • Checks honeypot field (hidden input, must be empty)
  • Checks a JS-generated timestamp token (rejects submissions < 3 seconds after page load)
  • Sends email via SMTP relay (Resend or Brevo)
  • Returns JSON response (success/error)
  • Rate-limited: max 5 submissions per IP per hour (in-memory counter, acceptable for low traffic)

Spam protection layers (no CAPTCHA needed):

  1. Honeypot hidden field
  2. JavaScript timestamp token (bots that don't execute JS fail)
  3. Server-side rate limiting per IP

3.5 Docker setup

website/Dockerfile (multi-stage):

# Stage 1: Build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Serve
FROM nginx:stable-alpine-slim
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

form-handler/Dockerfile:

FROM golang:1.22-alpine AS build
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o form-handler .

FROM alpine:3.20
COPY --from=build /app/form-handler /usr/local/bin/
EXPOSE 8080
CMD ["form-handler"]

docker-compose.yml (production):

services:
  website:
    build: ./website
    container_name: qumo-website
    restart: unless-stopped
    expose:
      - "80"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/"]
      interval: 30s
      timeout: 3s
      retries: 3
    networks:
      - qumo_services_proxy_network

  form-handler:
    build: ./form-handler
    container_name: qumo-form-handler
    restart: unless-stopped
    expose:
      - "8080"
    env_file:
      - .env
    networks:
      - qumo_services_proxy_network

networks:
  qumo_services_proxy_network:
    external: true

3.6 Caddy configuration

Add to the existing Caddyfile on the VPS:

# ============================================================
# qumo.io — company website
# ============================================================
qumo.io, www.qumo.io {
    encode gzip

    # Security headers (better than Webflow standard plans)
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
        Cross-Origin-Opener-Policy "same-origin"
        -Server
        -X-Powered-By
    }

    # Caching: immutable for hashed assets, revalidate for HTML
    @immutable path /assets/* /_astro/*
    header @immutable Cache-Control "public, max-age=31536000, immutable"
    @html path *.html /
    header @html Cache-Control "public, max-age=0, must-revalidate"

    # Contact form API → form handler container
    handle /api/contact {
        reverse_proxy qumo-form-handler:8080
    }

    # Everything else → website container
    handle {
        reverse_proxy qumo-website:80
    }

    # www → apex redirect
    @www host www.qumo.io
    handle @www {
        redir https://qumo.io{uri} permanent
    }
}

Note: This block does NOT use import authentik — the public website should be unauthenticated.

3.7 SEO and structured data

Every page includes via the base layout:

  • <title> and <meta name="description"> from the i18n content files
  • <link rel="canonical"> pointing to the current URL
  • <link rel="alternate" hreflang="en"> and hreflang="nl" pointing to both versions
  • Open Graph tags (og:title, og:description, og:image, og:url, og:type)
  • Twitter card tags
  • JSON-LD structured data:
    • Homepage: ProfessionalService schema (organization info, contact, social links)
    • About page: Organization + Person schemas for each team member
    • Contact page: ContactPage schema
    • All pages: BreadcrumbList schema

robots.txt allows all crawlers including AI bots (GPTBot, ClaudeBot, PerplexityBot).

sitemap.xml is auto-generated by @astrojs/sitemap at build time with both EN and NL URLs.

3.8 Image optimization

Astro's built-in <Image> component handles everything at build time:

  • Converts to AVIF (primary) with WebP fallback
  • Generates responsive srcset at 400, 800, 1200, 1920px widths
  • Adds width, height, loading="lazy", decoding="async" automatically
  • Output goes to /_astro/ with content hashes (immutable caching)

Team photos, SVG illustrations, and the logo are stored in src/assets/. The public/ directory is only for files that must not be processed (favicon, og-image).


4. .claude/ directory — Claude Code project configuration

.claude/CLAUDE.md — project context

This file is loaded automatically at the start of every Claude Code session. It lives inside .claude/ so all project configuration is in one place.

# qumo.io Website

## What this is

The company website for Qumo (qumo.io), a data analytics consultancy targeting Dutch MKB companies. Built with Astro 5, Tailwind CSS, and served from Docker containers behind Caddy.

## Execution environment

Claude Code runs inside a nono sandbox (`nono run --profile claude-openspec -- claude`).
- The sandbox grants read+write to the working directory and `~/.claude`
- Network access is enabled (Anthropic API, npm registry, Astro MCP server)
- All filesystem and network boundaries are kernel-enforced by nono (Landlock on Linux)
- Do NOT attempt to access files outside the project directory or `~/.claude`

## Architecture

- **Framework**: Astro 5 (static output, zero JS by default)
- **Styling**: Tailwind CSS with custom Qumo brand tokens
- **Font**: Archia (self-hosted woff2, licensed from Atipo Foundry)
- **Localization**: EN (default) + NL, file-based routing with JSON content strings
- **Contact form**: Go backend at /api/contact, honeypot + timestamp spam protection
- **Deployment**: Docker multi-stage build → nginx:stable-alpine-slim → Caddy reverse proxy
- **Hosting**: Self-hosted VPS behind Cloudflare (orange cloud, Full strict TLS)
- **Git**: Gitea at gitea.qumo.io

## Brand guidelines (must follow)

- **Colors**: Midnight #102022 (primary dark), Snow #F3F3F3 (light bg), Gradient #5257E4#F71E3E (accent only, never as flat standalone colors)
- **Font**: Archia — Bold/SemiBold for headings (uppercase), Regular for body (sentence case)
- **Icons**: Phosphor Icons, light weight, 32px baseline, SVG format
- **Logo**: Q icon can stand alone. Wordmark must always pair with Q icon. Never modify.
- **Brand element**: Q-corner motif, can be used as corner accent or tiled pattern

## Code conventions

- Astro components use `.astro` extension
- All text content comes from `src/content/en.json` and `src/content/nl.json` — never hardcode user-visible strings in components
- CSS uses Tailwind utilities. Custom CSS only for animations and font-face declarations
- Images go in `src/assets/` and use Astro's `<Image>` component (never raw `<img>` tags for raster images)
- SVGs can be inlined directly in components
- No client-side JavaScript frameworks (React, Vue, etc.) — use vanilla JS in `<script>` tags when interactivity is needed
- Keep components small and single-purpose
- File naming: kebab-case for files, PascalCase for components

## Content rules (from Qumo company guidelines)

- Never mention team size, company size, or founding date
- Never name clients in public content (use descriptive labels: "a shipping company")
- The team section shows 5 co-founders: Luigi, Matthijs, Jelle, Jorn, Stijn
- Engagement models should not be listed as a menu — mention naturally
- Proof points are for calls/follow-ups, not the website

## SEO requirements

- Every page needs: unique <title>, meta description, canonical URL, hreflang alternates, OG tags
- JSON-LD structured data on every page (ProfessionalService, BreadcrumbList, etc.)
- All images need alt text
- Heading hierarchy must be correct (single h1 per page, logical h2→h3 nesting)
- sitemap.xml auto-generated by @astrojs/sitemap

## Testing before committing

- `cd website && npm run build` must succeed (catches broken links, missing imports)
- Check the built output in `website/dist/` for correct HTML structure
- Validate JSON-LD with Google Rich Results Test after deployment

## Current reference

The live Webflow site at https://www.qumo.io is the visual reference. Match the design, layout, and content — but improve the code quality, performance, and SEO.

.claude/settings.json — hooks and permissions

This file is committed to git and shared with anyone working on the project. It defines hooks that run automatically during Claude Code sessions.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "if echo \"$CLAUDE_FILE_PATHS\" | grep -qE '\\.(astro|ts|tsx|js|jsx|css|json)$'; then cd $CLAUDE_PROJECT_DIR/website && npx prettier --write \"$CLAUDE_FILE_PATHS\" 2>/dev/null; fi",
            "timeout": 10
          }
        ]
      }
    ]
  },
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)"
    ]
  }
}

This auto-formats Astro, TypeScript, CSS, and JSON files with Prettier after every edit, and prevents Claude from reading .env files containing secrets.

Nono profile: claude-openspec

You run Claude Code inside a nono sandbox with a custom profile that extends the built-in claude-code profile. This profile lives at ~/.config/nono/profiles/claude-openspec.json on your local machine (not in the repo). It was set up in a previous session.

The launch command for every development session is:

cd ~/path/to/qumo-website
nono run --profile claude-openspec -- claude

The nono sandbox provides kernel-enforced boundaries (Landlock on Ubuntu 24.04):

  • Read+write access to the working directory (the repo)
  • Read+write access to ~/.claude (Claude Code state, hooks, skills)
  • Network access enabled (Anthropic API, npm, MCP servers)
  • Automatic hook that tells Claude about sandbox restrictions when file operations fail

Because nono is the sandbox layer, Claude Code's built-in sandbox should be disabled to avoid double-sandboxing. The claude-openspec profile handles this.


5. OpenSpec configuration

openspec/config.yaml

schema: spec-driven

context: |
  Project: qumo.io company website rebuild
  Stack: Astro 5, Tailwind CSS, TypeScript (minimal), Go (form handler)
  Deployment: Docker (nginx:stable-alpine-slim) behind Caddy reverse proxy on VPS
  Localization: English (default) + Dutch, file-based routing, JSON content strings
  Font: Archia (self-hosted woff2, licensed). Fallback: geometric sans-serif
  Brand colors: Midnight #102022, Snow #F3F3F3, Gradient #5257E4 → #F71E3E
  Icons: Phosphor Icons, light weight, SVG, 32px
  Form: Go backend, honeypot + JS timestamp + rate limiting. SMTP via Resend or Brevo.
  Git: Gitea (self-hosted). No CI/CD pipeline yet — manual docker compose build + up.
  AI tooling: Claude Code with Astro Docs MCP server.
  Visual reference: https://www.qumo.io (live Webflow site, match design but improve code)

  Constraints:
  - Zero JavaScript shipped by default (Astro islands only where needed)
  - All user-visible text in JSON content files, never hardcoded
  - No client-side frameworks (React, Vue, etc.)
  - Images processed by Astro <Image> component, not raw <img>
  - Security headers configured in Caddy, not in the app

rules:
  proposal:
    - Reference the visual design at qumo.io when describing UI changes
    - Note which content strings (en.json / nl.json) are affected
    - Identify if the change affects SEO (structured data, meta tags, sitemap)
  specs:
    - Include both EN and NL content in requirements where applicable
    - Specify responsive behavior (mobile, tablet, desktop breakpoints)
  design:
    - Use Astro component patterns (no React/Vue)
    - Reference Tailwind utility classes, not custom CSS where possible
    - Specify which Phosphor icon names to use
  tasks:
    - Each task should be completable in a single Claude Code session
    - Build verification: npm run build must pass after each task
    - Tasks that add new pages must include structured data and meta tags

6. MCP server configuration

.mcp.json (project-level, for Claude Code)

{
  "mcpServers": {
    "Astro docs": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://mcp.docs.astro.build/mcp"]
    }
  }
}

This gives Claude Code access to the latest Astro documentation while generating code. It is loaded automatically when Claude Code starts in the project directory.


7. Setup steps — getting started

Follow these steps in order on your local development machine (Ubuntu 24.04).

Step 1: Prerequisites

Verify that your claude-openspec nono profile is working:

# Verify nono is installed
nono --version

# Verify the claude-openspec profile exists
nono policy show claude-openspec

# Verify Node.js 20+ (provided by the profile or your system)
node --version   # must be >= 20.19.0

# Verify OpenSpec is installed
openspec --version

# Verify Claude Code is installed
claude --version

If any of these are missing, install them. Node.js 20+ is required for both OpenSpec and Astro. Go 1.22+ is needed for the form handler but can be deferred.

Step 2: Create the repository on Gitea

Create a new repository qumo-website on your Gitea instance. Then clone it locally:

git clone git@gitea.qumo.io:qumo/qumo-website.git
cd qumo-website

Step 3: Initialize the Astro project

# Create the Astro project inside the website/ directory
npm create astro@latest website -- --template minimal --no-install --typescript strict

# Enter the website directory and install dependencies
cd website
npm install
npm install @astrojs/tailwind @astrojs/sitemap tailwindcss
npm install -D @tailwindcss/typography prettier prettier-plugin-astro

# Verify it works
npm run dev    # should start at localhost:4321
# Ctrl+C to stop

cd ..

Step 4: Create the .claude/ directory and files

mkdir -p .claude

Copy the contents of .claude/CLAUDE.md from Section 4 of this document.

Copy the contents of .claude/settings.json from Section 4 of this document.

# Verify the structure
ls -la .claude/
# Should show: CLAUDE.md  settings.json

Step 5: Create the MCP server config

Copy the contents from Section 6 into .mcp.json at the repo root.

Step 6: Initialize OpenSpec

# Initialize OpenSpec in the repo root
openspec init

# When prompted, select the 'expanded' workflow profile for access to
# /opsx:new, /opsx:ff, /opsx:continue, /opsx:verify, /opsx:archive

# Then apply the profile
openspec config profile   # select 'expanded'
openspec update           # regenerate skill files in .claude/skills/

After init, replace the generated openspec/config.yaml with the contents from Section 5 of this document.

Verify that OpenSpec generated its skills inside .claude/skills/:

ls .claude/skills/
# Should show OpenSpec skill files

Step 7: Create the initial directory structure

# Content and asset directories
mkdir -p website/src/{layouts,components,pages/nl,content,styles}
mkdir -p website/src/assets/{fonts/archia,images,icons}
mkdir -p website/public

# Form handler (scaffold — implementation comes later)
mkdir -p form-handler

# Copy Archia font files (woff2) from SharePoint into:
#   website/src/assets/fonts/archia/
# You need: archia-thin, archia-light, archia-regular,
#           archia-medium, archia-semibold, archia-bold (woff2 format)

Step 8: Initial commit

# Create .gitignore
cat > .gitignore << 'EOF'
node_modules/
dist/
.env
.DS_Store
*.log
website/.astro/
.claude/settings.local.json
EOF

git add -A
git commit -m "Initial project scaffold: Astro + OpenSpec + Claude Code config"
git push

Step 9: Start Claude Code inside nono

cd ~/path/to/qumo-website
nono run --profile claude-openspec -- claude

Claude Code will automatically read .claude/CLAUDE.md for project context and .mcp.json to connect to the Astro Docs MCP server. Verify both:

> /mcp

You should see "Astro docs" listed as a connected server.

> /config

Verify that hooks from .claude/settings.json are loaded (PostToolUse for Prettier formatting).

Nono's automatic hook integration will also inject sandbox context into ~/.claude/CLAUDE.md (the global one, not the project one) so Claude understands the sandbox boundaries.


8. Development workflow with OpenSpec

Each feature or page is built as an OpenSpec change. This keeps work organized and gives Claude Code structured context for each task.

The cycle

/opsx:new <change-name>     # Scaffold a new change
/opsx:ff                     # Generate all planning docs (proposal, specs, design, tasks)
                             # ↑ Review these, edit if needed, then:
/opsx:apply                  # Implement the tasks
/opsx:verify                 # Check the implementation against specs
/opsx:archive                # Archive completed change, merge specs

Suggested change sequence for the rebuild

Build the site incrementally, one change at a time. Each change should produce a working (if incomplete) site.

Order Change name What it produces
1 setup-base-layout BaseLayout.astro with <head>, meta tags, OG tags, JSON-LD shell, Archia font-face, Tailwind config, global styles
2 add-nav-and-footer Nav.astro (responsive, mobile hamburger, language switcher), Footer.astro, both EN + NL
3 build-homepage Homepage sections: hero, services cards, stats ticker, approach steps, CTA. Both locales
4 build-about-page About page: mission, values, team members, expertise. Both locales
5 build-contact-page Contact form component (frontend only, POST to /api/contact). Both locales
6 build-form-handler Go backend: validation, honeypot, timestamp, SMTP sending, rate limiting
7 add-structured-data JSON-LD for all pages: ProfessionalService, Person, BreadcrumbList, ContactPage
8 add-scroll-animations Fade-in/slide-up on scroll for sections. CSS + IntersectionObserver
9 add-404-and-privacy 404 page, privacy policy page, both locales
10 docker-and-deployment Dockerfiles, docker-compose.yml, nginx.conf, Caddyfile block, .env.example
11 seo-final-pass Sitemap config, robots.txt, canonical URLs, hreflang validation, OG image

Example: starting the first change

cd ~/path/to/qumo-website
nono run --profile claude-openspec -- claude

# In Claude Code:
> /opsx:new setup-base-layout
# Claude generates the change scaffold

> /opsx:ff
# Claude generates proposal.md, specs/, design.md, tasks.md
# Review them — edit anything that's wrong

> /opsx:apply
# Claude implements the tasks one by one

# After implementation:
> cd website && npm run build   # verify it compiles
> cd website && npm run dev     # check in browser

> /opsx:verify    # Claude checks implementation against specs
> /opsx:archive   # Done, specs merged
> git add -A && git commit -m "feat: base layout with head, meta, Tailwind, Archia font"
> git push

9. Deployment workflow

Once the site is ready to go live:

On the VPS

# Clone the repo
git clone git@gitea.qumo.io:qumo/qumo-website.git ~/apps/qumo-website
cd ~/apps/qumo-website

# Create .env from template
cp .env.example .env
# Edit .env with SMTP credentials (Resend API key or Brevo SMTP)

# Build and start
docker compose up -d --build

# Verify containers are running
docker ps | grep qumo

Update the Caddyfile

Add the qumo.io block from Section 3.6 to ~/networking/caddy/Caddyfile. Reload Caddy:

cd ~/networking/caddy
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

Update Cloudflare DNS

Change the qumo.io A record to point to the VPS IP (orange cloud proxied). If it currently points to Webflow, this is the cutover moment.

Verify

  • Visit https://qumo.io — should load the new site
  • Visit https://qumo.io/nl — Dutch version
  • Submit a test contact form
  • Check https://securityheaders.com/?q=qumo.io — should be A+
  • Run PageSpeed Insights on mobile
  • Submit sitemap to Google Search Console

Subsequent deploys

cd ~/apps/qumo-website
git pull
docker compose up -d --build

The nginx container rebuilds with the new static files. Caddy picks up the new container automatically. Downtime is 2-3 seconds (acceptable for a marketing site).


10. What this plan intentionally defers

These are explicitly out of scope for v1 but can be added as OpenSpec changes later:

Item Why deferred
CI/CD pipeline (Gitea Actions) Manual deploy is fine for a low-change marketing site. Add when deploy frequency justifies it.
Umami analytics Not critical for launch. Add as a separate Docker service + Caddy block.
AI product page Content exists in SharePoint draft form. Add when the offering is finalized.
Blog No content ready. Astro supports markdown-based blogs natively when needed.
CSP report-only → enforced Start with enforced but may need report-uri to catch issues. Adjust after launch.
Uptime monitoring (Uptime Kuma) Already in the infrastructure plan. Connect after launch.
Image CDN / edge caching Cloudflare's proxy already caches static assets. Sufficient for current traffic.