Senior freelance full-stack developer portfolio. Italian agencies & SaaS as primary audience. Trilingual (IT / EN / ES), light + dark, mobile-first.
This is the design specification — tokens, layout, behaviour, and component
states. The design is already implemented in the Astro 5 + Vue 3 islands +
Tailwind v4 + PocketBase app under astro/ (see astro/README.md and
astro/DESIGN.md); the values below are the source of truth it was built from.
High-fidelity. All colors, type, spacing, radii, shadows, and motion timings are final. Every section has light + dark + mobile artboards. Section transitions, focus states, hover states, error/success form states, and the reduced-motion guard are all specified.
| Layer | Choice | Notes |
|---|---|---|
| Framework | Astro 5 | Static-first. One route per language: /, /en/, /es/ (default IT) — or use built-in Astro i18n |
| Interactive islands | Vue 3 | client:visible for form, nav, theme toggle; client:load only for nav |
| Styling | Tailwind v4 | @theme block in app.css; tokens below map 1:1 to Tailwind variables |
| i18n | Astro built-in i18n | astro.config.mjs i18n: { defaultLocale: 'it', locales: ['it','en','es'] }. astro-i18next is an alternative if you want runtime translations |
| Data | PocketBase | REST: GET /api/collections/projects/records, services/records. Per-request fetch in Astro (SSR) — always fresh, no rebuild |
| Contact | PocketBase | The Vue island POSTs straight to POST /api/collections/contacts/records. Honeypot (client-side) + PocketBase built-in rate limiting. No email service. |
Tailwind v4 reads @theme directly. The token file astro/src/styles/tokens.css is the source of truth; astro/src/styles/app.css mirrors it into @theme. Excerpt:
@import "tailwindcss";
@theme {
/* Brand — sage emerald. Never lime. */
--color-brand-50: #EAF3ED;
--color-brand-100: #CFE3D5;
--color-brand-200: #9CC8AA;
--color-brand-300: #5FA577;
--color-brand-400: #2F8556;
--color-brand-500: #1F6E4A; /* primary, light */
--color-brand-600: #15533A; /* hover */
--color-brand-700: #0E3F2C; /* press */
--color-brand-800: #0A3424;
--color-brand-900: #052017;
--color-brand-glow: #3FB07A; /* primary, dark — phosphor */
/* Warm neutrals */
--color-ink-0: #FFFFFF;
--color-ink-50: #FAFAF7;
--color-ink-100: #F4F4EF;
--color-ink-150: #EDEDE7;
--color-ink-200: #E2E2DA;
--color-ink-300: #CECEC4;
--color-ink-400: #9A9A8F;
--color-ink-500: #6E6E64;
--color-ink-600: #4A4A42;
--color-ink-700: #2A2A25;
--color-ink-900: #0E0E0C;
--color-ink-950: #070706;
--color-danger: #B4321F; /* light */
/* dark mode uses #E36D58 — see :root[data-theme="dark"] below */
/* Type */
--font-display: "Instrument Serif", "Times New Roman", serif;
--font-body: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", ui-monospace, "JetBrains Mono", monospace;
--text-d2: 88px; --text-d1: 64px;
--text-h1: 44px; --text-h2: 32px; --text-h3: 24px; --text-h4: 18px;
--text-bl: 18px; --text-b: 15px; --text-s: 13px; --text-m: 11px;
/* Spacing (4 px base) */
--spacing-1: 4px; --spacing-2: 8px; --spacing-3: 12px; --spacing-4: 16px;
--spacing-5: 24px; --spacing-6: 32px; --spacing-7: 48px; --spacing-8: 64px;
--spacing-9: 96px; --spacing-10: 128px;
/* Radii / shadow / motion */
--radius-1: 2px; --radius-2: 4px; --radius-3: 8px; --radius-4: 14px;
--shadow-1: 0 1px 0 rgb(14 14 12 / .04), 0 1px 2px rgb(14 14 12 / .04);
--shadow-2: 0 1px 0 rgb(14 14 12 / .04), 0 6px 18px rgb(14 14 12 / .06);
--shadow-3: 0 12px 36px rgb(14 14 12 / .10);
--ease: cubic-bezier(.2,.7,.3,1);
}
/* Semantic surface vars — swap on data-theme */
:root {
--bg: var(--color-ink-50);
--surface: var(--color-ink-0);
--line: var(--color-ink-200);
--text: var(--color-ink-900);
--text-2: var(--color-ink-600);
--text-3: var(--color-ink-500);
--brand: var(--color-brand-500);
--on-brand: var(--color-ink-0);
}
:root[data-theme="dark"] {
--bg: var(--color-ink-950);
--surface: var(--color-ink-900);
--line: #232321;
--text: #F1F1EA;
--text-2: #B7B7AC;
--text-3: #80807A;
--brand: var(--color-brand-glow);
--on-brand: #062014;
}
/* WCAG hit-area + reduced motion */
@media (pointer: coarse) {
button[aria-label], [role="group"] button { min-height: 44px; min-width: 44px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}src/
├─ pages/
│ ├─ index.astro # IT (default)
│ ├─ en/index.astro
│ ├─ es/index.astro
│ └─ projects/[slug].astro # case-study detail
├─ layouts/
│ └─ Base.astro # html lang, meta, font preload, theme bootstrap, Nav, Footer slot
├─ components/
│ ├─ astro/ # static, no JS shipped
│ │ ├─ Hero.astro
│ │ ├─ Services.astro
│ │ ├─ Projects.astro
│ │ ├─ Skills.astro
│ │ ├─ SectionDivider.astro
│ │ └─ Footer.astro
│ └─ vue/ # interactive islands
│ ├─ Nav.vue # mobile drawer + theme toggle + lang switch
│ ├─ ThemeToggle.vue
│ ├─ LangSwitch.vue
│ └─ ContactForm.vue # validation + direct POST to PocketBase + aria-live feedback
├─ i18n/
│ ├─ it.json
│ ├─ en.json
│ └─ es.json
├─ lib/
│ ├─ pocketbase.ts # build-time fetch helpers
│ └─ contrast.ts # optional: theme bootstrap helper
└─ styles/
└─ app.css # @theme + base resets
| Component | Type | Notes |
|---|---|---|
| Nav | Vue island | Needs reactive state for mobile drawer open, scroll-shadow toggle, theme + lang switch. client:load (above-fold). Sticky w/ backdrop-filter: blur(8px). |
| ThemeToggle | Vue island (child of Nav) | Reads/writes localStorage.theme + document.documentElement.dataset.theme. See "Dark mode" below. |
| LangSwitch | Vue island (child of Nav) | ARIA radiogroup, arrow-key nav. Routes to /, /en/, /es/ preserving the current path. |
| Hero | Astro static | Atmosphere (radial mesh + dot grid mask + SVG grain) is pure CSS/SVG. CTAs are <a> not Vue. |
| Services | Astro static | Card grid; data from PocketBase. Icons are 5 inline line-SVGs (stack / api / vps / compass / magnifier). |
| Projects | Astro static | Featured + grid. Data from PocketBase. Hover state = CSS only. |
| Skills | Astro static | Pure list. No JS. Three pill weights: primary / daily / default — class-driven. |
| ContactForm | Vue island | client:visible. Live validation, char counter, aria-live="polite" region for submit feedback, honeypot field, POSTs to ${PUBLIC_PB_URL}/api/collections/contacts/records. |
| Footer | Astro static | Always-dark surface. Social icons inline SVG. Lang switch is a duplicate of Nav's — extract LangSwitch.vue once. |
| SectionDivider | Astro static | The § NN — name hairline between sections. Aria-hidden. |
| Field | Type | Required | Notes |
|---|---|---|---|
slug |
text (unique) | ✓ | URL slug, lowercased, no spaces |
idx |
text | ✓ | Display index, e.g. "01" |
client |
text | ✓ | e.g. "Atelier Nove" |
period |
text | ✓ | e.g. "2024 — ongoing" |
featured |
bool | ✓ | One featured at a time recommended |
order |
number | ✓ | Manual sort (lower = first) |
title_it / title_en / title_es |
text | ✓ | Per-locale title |
desc_it / desc_en / desc_es |
text (long) | ✓ | Per-locale description |
stack |
json | ✓ | Array of strings: ["Laravel","Vue","Nuxt","Stripe"] |
kpis |
json | – | Array of { label, value } for featured cards |
cover |
file (image) | ✓ | 16:10 hero shot |
gallery |
file (multi) | – | Case-study detail images |
live_url |
url | – | External link |
repo_url |
url | – | If public |
| Field | Type | Required | Notes |
|---|---|---|---|
idx |
text | ✓ | "01"–"05" |
order |
number | ✓ | |
icon |
select | ✓ | One of stack/api/vps/compass/magnifier — maps to inline SVG, not stored |
title_it / title_en / title_es |
text | ✓ | |
desc_it / desc_en / desc_es |
text (long) | ✓ | |
tags |
json | – | e.g. ["Laravel","Nuxt"] |
featured |
bool | – | Renders as wide card (positions 4–5 in the 5-card layout) |
// src/lib/pocketbase.ts
const PB = import.meta.env.PB_URL; // e.g. https://pb.miciodev.com
export async function getProjects(locale: 'it'|'en'|'es') {
const r = await fetch(`${PB}/api/collections/projects/records?sort=+order&perPage=50`);
const { items } = await r.json();
return items.map(p => ({
...p,
title: p[`title_${locale}`],
desc: p[`desc_${locale}`],
}));
}Use Astro's export const prerender = true and fetch in the page's frontmatter. Set up a webhook from PocketBase → trigger rebuild on collection change (Cloudflare Pages / Vercel hook URL).
The design supports system default + manual override. Source of truth: document.documentElement.dataset.theme ("light" | "dark" | unset = follow system).
Bootstrap (inline in <head>, blocking, no flash):
<script>
(function () {
var saved = localStorage.getItem('miciodev-theme');
var sys = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
var theme = saved || sys;
if (theme === 'dark') document.documentElement.dataset.theme = 'dark';
})();
</script>Place this before the CSS link — it sets data-theme before paint so the user never sees a flash of the wrong palette.
ThemeToggle.vue (sketch):
<script setup>
import { ref, onMounted } from 'vue';
const dark = ref(false);
onMounted(() => { dark.value = document.documentElement.dataset.theme === 'dark'; });
function toggle() {
dark.value = !dark.value;
document.documentElement.dataset.theme = dark.value ? 'dark' : 'light';
localStorage.setItem('miciodev-theme', dark.value ? 'dark' : 'light');
}
// Optional: listen to system change when user hasn't manually set
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('miciodev-theme')) {
dark.value = e.matches;
document.documentElement.dataset.theme = e.matches ? 'dark' : 'light';
}
});
</script>Footer stays dark in both modes — hard-code its background to #0A0B0A and don't read from --bg.
Two fonts — Instrument Serif (display, italic + roman) and Geist + Geist Mono (body & code accents). Performance first:
-
Self-host. Don't use Google Fonts CDN — it adds a third-party round trip. The woff2 files live in
astro/public/fonts/(committed), pulled from the Fontsource CDN —https://cdn.jsdelivr.net/fontsource/fonts/<id>@latest/latin-<wght>-<style>.woff2, idsinstrument-serif,geist,geist-mono. To refresh them, re-download the same files (names listed inpublic/fonts/.gitkeep). -
Subset to the languages you ship. The Fontsource
latinsubset already covers IT/EN/ES. To re-subset further, use glyphhanger or fonttoolspyftsubset. -
Preload the two critical files only (display italic + body regular):
<link rel="preload" href="/fonts/instrument-serif-italic.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/fonts/geist-regular.woff2" as="font" type="font/woff2" crossorigin>
-
font-display: swapon every@font-face. The system fallback (Times New Roman/system-ui) renders immediately; webfont swaps in when ready. -
Size-adjust + ascent-override to minimize layout shift when the swap happens:
@font-face { font-family: "Geist"; src: url("/fonts/geist-regular.woff2") format("woff2"); font-weight: 400; font-display: swap; size-adjust: 100%; ascent-override: 92%; }
-
Mono is non-critical — let it load late. Don't preload Geist Mono.
-
HTTP-cache fonts immutably (1 year,
cache-control: public, max-age=31536000, immutable) — add to your hosting headers config.
Expected outcome: text is paint-blocked for < 50 ms, italic display swaps in by ~200 ms on a fast connection, no perceptible CLS.
ContactForm.vuePOSTs JSON{ name, email, subject, message }straight to${PUBLIC_PB_URL}/api/collections/contacts/recordsfrom the browser — no Astro API route. Submissions land in the PocketBasecontactscollection; read them in the admin dashboard at${PUBLIC_PB_URL}/_/.- Fields:
name,email,subject(free-text),message, plus a honeypotwebsitefield that must be empty (a filled honeypot short-circuits to a fake success and sends nothing). - Client-side validation: all required, email format, message length 10–2000. PocketBase re-enforces the same bounds server-side via the collection schema.
- Spam: enable PocketBase rate limiting (Settings → Rate limits) on
POST /api/collections/contacts/records. - On failure the form shows a generic message only — PocketBase's response body
is never surfaced. Success/error appear in an
aria-live="polite"region above the submit button. - Environment:
PUBLIC_PB_URL(runtime).
Section heights:
| § | Section | Desktop pad | Mobile pad | Notes |
|---|---|---|---|---|
| 01 | Hero | 0 (full-bleed) | 0 | atmospheric mesh + grain; H1 only here |
| 02 | Services | 96 v · 64 h | 56 v · 22 h | 3-col + 2-wide |
| 03 | Projects | 96 v · 64 h | 56 v · 22 h | 1 featured + 4 grid |
| 04 | Skills | 96 v · 64 h | 56 v · 22 h | 4 group columns |
| 05 | Contact | 120 v · 64 h | 64 v · 22 h | form left, aside right |
| – | Footer | 88 v · 64 h | 56 v · 22 h | always dark |
Section dividers: a 1 px hairline + mono § NN — name label in the gutter. No hard borders.
- One H1 per page · H2 per section · H3 inside cards
- Form
<label htmlFor>always visible above input — never placeholder-as-label - Focus ring on every interactive element (3 px
--focus-ring) - Color contrast ≥ AA in both modes (brand 7.4 : 1 light, 9.1 : 1 dark)
-
prefers-reduced-motionhonored (blanket reset intokens.css) - Touch targets ≥ 44 × 44 on coarse pointers (CSS media query)
-
aria-live="polite"on form feedback region (add in Vue island) - LangSwitch keyboard arrow-key nav (ARIA radiogroup pattern)
- Skip-to-content link in
<body>first child
<html lang="…">set per route- Per-route
<title>and<meta name="description">from i18n JSON <link rel="alternate" hreflang>triplet on every page- Open Graph + Twitter card images at 1200×630 (generate from the hero per-locale)
sitemap.xmlvia@astrojs/sitemaprobots.txtallowing all
- Scaffold Astro + Vue + Tailwind v4 + i18n routes (no content yet).
- Drop tokens into
app.css; verify light/dark surfaces via a sandbox page. - Load fonts (self-host, subset, preload critical two).
- Build Nav.vue + ThemeToggle.vue + LangSwitch.vue. Verify theme bootstrap doesn't flash.
- Build Hero, Services, Skills as Astro static — content from i18n JSON (no PocketBase yet).
- Stand up PocketBase locally with
projectsandservicescollections; seed with placeholder data mirroring their schema. - Wire build-time fetch helpers; swap Services + Projects to read from PocketBase.
- Build ContactForm.vue posting directly to the PocketBase
contactscollection. - Footer.
- SEO + sitemap + analytics (Plausible recommended).
- Lighthouse + axe pass.
Aim for Lighthouse 100 / 100 / 100 / 100 on the IT landing page before launching the others.
The portfolio is split into two services. CI runs on every PR; merges to
developdeploy to staging and merges tomaindeploy to production (git-flow). Images are built and pushed to GitHub Container Registry, then a short SSH step rolls the stack on the VPS.The Astro app runs SSR (
output: 'server'), so every page re-reads PocketBase on each request — content (projects, services) is live without a redeploy. The contact form POSTs straight from the browser to PocketBase's REST API; submissions land in acontactscollection the admin reads atpb.micio86dev.it/_/. No email service is involved.
portfolio/
├── astro/ Astro 5 SSR frontend (@astrojs/node standalone server)
│ ├── Dockerfile multi-stage: node build → node:24-alpine standalone runtime
│ └── .dockerignore
├── pb/ PocketBase backend
│ ├── Dockerfile debian:bookworm-slim; downloads the pinned PB linux release
│ ├── pb_migrations/ versioned schema migrations — committed, auto-applied on start
│ ├── pocketbase local (macOS) binary — gitignored
│ └── pb_data/ SQLite data — gitignored
├── docker-compose.yml local dev (builds from source)
├── docker-compose.prod.yml production (GHCR :latest, ports 4322/8090) — also the base for stage
├── docker-compose.stage.yml staging overrides (GHCR :develop, ports 4321/8091)
├── nginx/ host-nginx vhosts (reference copies; placed on the VPS by hand)
│ ├── micio86dev.conf prod app → :4322 (+ www→apex redirect)
│ ├── pb.micio86dev.it.conf prod PB → :8090
│ ├── stage.micio86dev.it.conf stage app → :4321 (HTTP Basic Auth, noindex)
│ └── pb-stage.micio86dev.it.conf stage PB → :8091 (noindex)
└── .github/workflows/
├── ci.yml PR → astro check + build
├── deploy-stage.yml push develop → build/push :develop → SSH deploy staging
└── deploy-prod.yml push main → build/push :latest → SSH deploy production
The app is SSR (output: 'server' + @astrojs/node standalone). The runtime
image runs node ./dist/server/entry.mjs, which renders pages on demand and
serves the static client assets (dist/client). Host nginx sits in front for
TLS, gzip, security headers, and asset caching, and exposes PocketBase on a
pb. subdomain.
| Variable | When | Where it comes from |
|---|---|---|
PUBLIC_PB_URL |
runtime — read by the SSR server (process.env) for projects/services, and by the browser for the contact-form POST |
container environment: in docker-compose.{prod,stage}.yml, interpolated from ${PROD_PB_URL} / ${STAGE_PB_URL} in the VPS .env (written by the deploy workflow from the repo secret). Falls back to http://localhost:8090. Not a build arg. |
SITE_URL |
build — canonical / hreflang / sitemap | Docker build arg ← hardcoded per env in the deploy workflow |
Because the contact form runs in the browser, PUBLIC_PB_URL must be a URL the
browser can reach (e.g. https://pb.micio86dev.it). PocketBase serves
permissive CORS on /api/*, so the cross-subdomain POST from the site works.
docker compose up --build
# site: http://localhost:4321
# PocketBase: http://localhost:8090/_/ (create the first superuser here)docker-compose.yml hardcodes PUBLIC_PB_URL=http://localhost:8090 (browser-
reachable via the port map). Inside the astro container, SSR-side fetches resolve
localhost to the container itself, so projects/services fall back to seed data;
for live local PocketBase data in SSR run bun run dev in astro/ on the host
(it reads astro/.env).
Two independent stacks, one directory each. Each holds a .env (just the
PocketBase URL — written by the deploy workflow from the PROD_PB_URL /
STAGE_PB_URL repo secret; not a real secret, but never committed) and a
pb_data/ directory the PocketBase container mounts. The deploy workflows
scp the compose files in on every run, so they stay in sync with the repo.
/var/www/html/micio86dev.it/ ← production (compose: -f docker-compose.prod.yml)
├── .env ← PROD_PB_URL=https://pb.micio86dev.it (CI writes this)
├── pb_data/ ← chown to uid 1001 (the container's pocketbase user)
└── docker-compose.prod.yml
/var/www/html/stage.micio86dev.it/ ← staging (compose: -f docker-compose.prod.yml -f docker-compose.stage.yml)
├── .env ← STAGE_PB_URL=https://pb-stage.micio86dev.it (CI writes this)
├── pb_data/
├── docker-compose.prod.yml
└── docker-compose.stage.yml
# one-time per stack, on the VPS:
sudo mkdir -p /var/www/html/micio86dev.it/pb_data
sudo chown -R 1001:1001 /var/www/html/micio86dev.it/pb_data
# .env is created by the first deploy; nothing to paste by hand.Host nginx — none of this is touched by CI; the deploy user is non-root and not a sudoer. One vhost file per domain, named after the domain; you place them by hand, once, as root, and re-do it only when one changes in the repo:
| repo file | → /etc/nginx/sites-available/ |
upstream |
|---|---|---|
nginx/micio86dev.conf |
micio86dev |
prod app :4322 (+ www→apex) |
nginx/pb.micio86dev.it.conf |
pb.micio86dev.it |
prod PocketBase :8090 |
nginx/stage.micio86dev.it.conf |
stage.micio86dev.it |
stage app :4321 (HTTP Basic Auth, noindex) |
nginx/pb-stage.micio86dev.it.conf |
pb-stage.micio86dev.it |
stage PocketBase :8091 (noindex) |
Each file is self-contained and carries its own :443 + ssl_certificate block,
so Certbot only ever renews the certs — it never has to rewrite these configs.
# 1. issue the certs first (so `nginx -t` passes — the files reference live certs)
sudo certbot certonly --nginx -d micio86dev.it -d www.micio86dev.it
sudo certbot certonly --nginx -d pb.micio86dev.it
sudo certbot certonly --nginx -d stage.micio86dev.it
sudo certbot certonly --nginx -d pb-stage.micio86dev.it
# 2. basic-auth for the staging app (never committed)
sudo apt install apache2-utils -y
sudo htpasswd -c /etc/nginx/.htpasswd_portfolio_stage YOUR_USERNAME
# 3. drop the vhosts in and enable them
sudo cp nginx/micio86dev.conf /etc/nginx/sites-available/micio86dev
sudo cp nginx/pb.micio86dev.it.conf /etc/nginx/sites-available/pb.micio86dev.it
sudo cp nginx/stage.micio86dev.it.conf /etc/nginx/sites-available/stage.micio86dev.it
sudo cp nginx/pb-stage.micio86dev.it.conf /etc/nginx/sites-available/pb-stage.micio86dev.it
for s in micio86dev pb.micio86dev.it stage.micio86dev.it pb-stage.micio86dev.it; do
sudo ln -sf /etc/nginx/sites-available/$s /etc/nginx/sites-enabled/$s
done
sudo nginx -t && sudo systemctl reload nginxSchema (and seed data) are versioned as migration files under pb/pb_migrations/
(committed). PocketBase auto-applies pending migrations on startup, and the
deploy workflows also run pocketbase migrate up after each release. All page
content lives in PocketBase and is editable per language (en / it / es)
from the admin dashboard at https://pb.micio86dev.it/_/:
| Collection | Created by | What it holds | Read rule |
|---|---|---|---|
contacts |
…_created_contacts.js |
contact-form submissions: name (2–100), email, subject (≤200), message (10–2000), read (bool), ip |
admins only (createRule = "" so anyone can submit) |
translations |
…_created_translations.js |
every UI string, one row per key with en / it / es columns (key like hero.headline.line1, group = first segment). Overlaid on the bundled astro/src/i18n/*.json at runtime. |
public |
services |
…_created_services.js |
the five §02 service cards: idx, order, icon (select), featured, title_* / desc_*, tags (json) |
public |
projects |
…_created_projects.js |
§03 case-study cards: slug, idx, client, clientInitials, period, featured, order, title_* / desc_*, stack (json), kpis (json), live_url, repo_url |
public |
skills |
…_created_skills.js |
§04 tech pills: group (select), name, weight (select), order (names aren't translated) |
public |
news |
…_created_news.js |
long-form posts carried over from the old site: slug, date, published, order, cover (file), tags (json), title_* / excerpt_* / body_* (body = rich text) |
public, published = true only |
customers |
…_created_customers.js |
clients carried over from the old site: slug, name, sector, url, logo (file), featured, order, description_* / testimonial_*, testimonial_author |
public |
The Astro frontend reads these on every request (SSR), so edits are live with no
redeploy; if PUBLIC_PB_URL is unset or the instance is unreachable it falls
back to the bundled seed data (astro/src/lib/seed-data.ts) and the bundled
i18n JSON, so the site still renders. Per-request translation warm-up happens in
astro/src/middleware.ts (30 s cache).
contacts submissions land in the dashboard; read and mark them there. There is
no email delivery. Spam: enable PocketBase's built-in rate limiting
(Settings → Rate limits) with a rule on POST /api/collections/contacts/records;
the form also carries a website honeypot dropped client-side.
To add more schema changes later: edit collections in the admin UI with the
server running with --dev (auto-generates a migration file), or run
./pocketbase migrate collections to snapshot the current schema, then commit
the new file in pb/pb_migrations/.
Not configured yet. Once a lint script is added to astro/package.json,
uncomment the lint step in .github/workflows/ci.yml.
Add these under Settings → Secrets and variables → Actions → New repository secret.
| Secret | Description | Example format | Used in |
|---|---|---|---|
VPS_SSH_PRIVATE_KEY |
ED25519 private key the workflows use to SSH into the VPS | -----BEGIN OPENSSH PRIVATE KEY-----b3BlbnNzaC1rZXkt...-----END OPENSSH PRIVATE KEY----- |
deploy-stage, deploy-prod |
VPS_SSH_HOST |
VPS IP address or hostname | 203.0.113.10 or vps.micio86dev.it |
deploy-stage, deploy-prod |
VPS_SSH_USER |
SSH user on the VPS (a dedicated low-privilege deploy user, in the docker group) |
deploy |
deploy-stage, deploy-prod |
STAGE_PB_URL |
Runtime URL of the staging PocketBase. The deploy workflow writes it into /var/www/html/stage.micio86dev.it/.env; docker-compose.stage.yml injects it as the astro container's PUBLIC_PB_URL at start — not at build time. |
https://pb-stage.micio86dev.it |
deploy-stage |
PROD_PB_URL |
Runtime URL of the production PocketBase. The deploy workflow writes it into /var/www/html/micio86dev.it/.env; docker-compose.prod.yml injects it as the astro container's PUBLIC_PB_URL at start — not at build time. |
https://pb.micio86dev.it |
deploy-prod |
These are public hostnames, not secrets — they're stored as repo secrets only so
the deploy workflow can drop them onto the VPS without anyone editing files
there. Contact-form submissions are stored in the PocketBase contacts
collection, viewable in the admin dashboard at /_/ — no email service, so
there are no Resend / SMTP credentials anywhere.
GITHUB_TOKEN is automatic — the deploy workflows log in to ghcr.io with
the built-in ${{ secrets.GITHUB_TOKEN }} (permissions: packages: write) both
on the runner (to push) and on the VPS (to pull the private images — the token
is piped over SSH via stdin and docker logout-ed at the end of the job). No
PAT to create or store.
# on your machine — no passphrase (CI can't type one)
ssh-keygen -t ed25519 -C "github-actions-portfolio" -f ./ci_ed25519
# install the PUBLIC key on the VPS, for the deploy user
ssh-copy-id -i ./ci_ed25519.pub deploy@<VPS_HOST>
# …or append ci_ed25519.pub to /home/deploy/.ssh/authorized_keys manually
# put the PRIVATE key (the whole file, including the BEGIN/END lines) into the
# VPS_SSH_PRIVATE_KEY secret, then delete ci_ed25519 locallyThe deploy workflows connect with StrictHostKeyChecking=accept-new (trust on
first use). The key is loaded into an ssh-agent via webfactory/ssh-agent — it
is never written to disk in the runner and never printed.
ci.yml runs with contents: read only. deploy-stage.yml / deploy-prod.yml
add packages: write (for the GHCR push) and nothing else. Every job has
timeout-minutes: 10. No curl | bash, no secret echoed in any run step.