feat: publish public website at furtka.org
Some checks failed
CI / lint (push) Successful in 24s
CI / test (push) Successful in 32s
CI / validate-json (push) Successful in 23s
CI / markdown-links (push) Failing after 2s

Hugo static site with an intentionally minimal single-page copy — English
default, German under /de/ — while the project stays pre-alpha. No CMS, no
external theme, no webfonts, no external requests. System-UI sans on a
paper-white / near-black palette with a deep crimson accent; a small
wicket-gate SVG as the sole brand mark.

Hosting: nginx on forge-runner-01 serves /var/www/furtka.org; the upstream
openresty proxy terminates TLS so the VM itself only speaks plain HTTP.
Deploy is ./website/deploy.sh (rsync + remote hugo --minify). One-time VM
bootstrap in ops/nginx/setup-vm.sh.
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-14 10:27:51 +02:00
parent 7f15543f1c
commit defd2eda06
20 changed files with 604 additions and 0 deletions

5
.gitignore vendored
View file

@ -8,3 +8,8 @@ __pycache__/
# Real credentials must never be committed — use the .example files # Real credentials must never be committed — use the .example files
archinstall/user_credentials.json archinstall/user_credentials.json
iso/out/ iso/out/
# Hugo website
website/public/
website/resources/
website/.hugo_build.lock

View file

@ -17,6 +17,7 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
- **Forgejo Actions runner** live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) with DinD sidecar — CI green end-to-end. Setup scripts in `ops/forgejo-runner/`. - **Forgejo Actions runner** live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) with DinD sidecar — CI green end-to-end. Setup scripts in `ops/forgejo-runner/`.
- **Walking-skeleton live ISO** (`iso/build.sh`). Overlays an Arch `releng` profile with Flask + the webinstaller, bakes a systemd unit that auto-starts the wizard on boot, produces a hybrid BIOS/UEFI ISO via `mkarchiso` in a privileged `archlinux:latest` container. Tested booting under OVMF in Proxmox — wizard screens 13 respond at `http://<vm-ip>:5000`. - **Walking-skeleton live ISO** (`iso/build.sh`). Overlays an Arch `releng` profile with Flask + the webinstaller, bakes a systemd unit that auto-starts the wizard on boot, produces a hybrid BIOS/UEFI ISO via `mkarchiso` in a privileged `archlinux:latest` container. Tested booting under OVMF in Proxmox — wizard screens 13 respond at `http://<vm-ip>:5000`.
- **Public website at [furtka.org](https://furtka.org)** (`website/`). Hugo static site, English + German, served from `/var/www/furtka.org` on `forge-runner-01` via nginx. Upstream openresty proxy handles TLS. Intentionally minimal single-page copy while the project is pre-alpha. Deploy is `./website/deploy.sh` (rsync + remote Hugo build); one-time VM setup in `ops/nginx/setup-vm.sh`.
## [26.0-alpha] - 2026-04-13 ## [26.0-alpha] - 2026-04-13

28
ops/nginx/furtka.org.conf Normal file
View file

@ -0,0 +1,28 @@
server {
listen 80;
listen [::]:80;
server_name furtka.org www.furtka.org;
root /var/www/furtka.org;
index index.html;
charset utf-8;
location / {
try_files $uri $uri/ $uri.html =404;
}
location = /favicon.svg {
access_log off;
log_not_found off;
expires 7d;
}
location ~* \.(css|js|svg|woff2?|png|jpg|jpeg|webp|avif)$ {
access_log off;
expires 30d;
add_header Cache-Control "public, immutable";
}
error_page 404 /404.html;
}

27
ops/nginx/setup-vm.sh Executable file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env bash
# One-time setup on forge-runner-01 for furtka.org.
# Idempotent — safe to re-run.
#
# Usage (on the VM, with sudo):
# sudo ops/nginx/setup-vm.sh
set -euo pipefail
OWNER="${SUDO_USER:-daniel}"
WEBROOT="/var/www/furtka.org"
SRCROOT="/srv/furtka-site"
SITE_CONF="/etc/nginx/sites-available/furtka.org"
SITE_LINK="/etc/nginx/sites-enabled/furtka.org"
install -d -o "$OWNER" -g "$OWNER" -m 0755 "$WEBROOT"
install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT"
cp "$(dirname "$0")/furtka.org.conf" "$SITE_CONF"
ln -sfn "$SITE_CONF" "$SITE_LINK"
# Drop the Ubuntu default site so it doesn't shadow us on :80.
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx
echo "OK: furtka.org ready at $WEBROOT (owner $OWNER)"

61
website/README.md Normal file
View file

@ -0,0 +1,61 @@
# website/ — furtka.org
Hugo source for [furtka.org](https://furtka.org). Intentionally minimal while
the project is pre-alpha: a single idea page in English and German, nothing more.
More pages will come back when there's something real to show.
## Local build
```sh
cd website
hugo server # http://localhost:1313
```
Requires Hugo **extended** ≥ 0.140.
## Deploy
Hosted on `forge-runner-01` (Proxmox VM, Ubuntu 24.04). Hugo runs on the VM;
nginx serves the built output from `/var/www/furtka.org`. TLS is terminated by
an upstream openresty reverse proxy — the VM itself only speaks plain HTTP.
First time only, on the VM:
```sh
ssh forge-runner
sudo /srv/furtka-site/ops/nginx/setup-vm.sh # or copy the script over first
```
From then on, deploy from your dev machine:
```sh
./website/deploy.sh
```
The script rsyncs `website/` to `/srv/furtka-site/` on the VM and runs
`hugo --minify` into `/var/www/furtka.org`.
## Structure
```
hugo.toml Hugo config (multilingual: en default, de)
content/ Markdown pages
_index.md Home (EN)
_index.de.md Home (DE)
layouts/ Custom inline theme — no external theme or framework
_default/ baseof, single, list
partials/ head, header, footer, gate SVG, lang switcher
index.html Home-only layout with editorial hero
assets/css/main.css Stylesheet (fingerprinted + minified on build)
static/favicon.svg Gate mark in crimson
deploy.sh Rsync + remote Hugo build
```
## Design
Modern-minimal on paper-white light / near-black dark. System-UI sans
(no webfonts — zero external requests, matches the self-hosting ethos).
Deep crimson accent, `prefers-color-scheme` switch.
The gate SVG is the one brand mark — a small wicket-gate glyph repeated in the
header, footer, and favicon.

260
website/assets/css/main.css Normal file
View file

@ -0,0 +1,260 @@
:root {
--bg: #f7f6f3;
--bg-subtle: #efeee8;
--fg: #0e0e0f;
--fg-muted: #6b6b6f;
--accent: #c03a28;
--accent-hover: #a0301f;
--border: #e4e3dc;
--font-sans:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "Noto Sans", sans-serif;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
--measure: 32rem;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d0d0f;
--bg-subtle: #17171a;
--fg: #ececee;
--fg-muted: #8a8a90;
--accent: #ff6b56;
--accent-hover: #ff8b78;
--border: #232326;
}
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
font-size: 1.0625rem;
line-height: 1.65;
font-feature-settings: "kern", "cv11", "ss01";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: flex;
flex-direction: column;
min-height: 100vh;
text-rendering: optimizeLegibility;
}
.container {
max-width: 52rem;
margin-inline: auto;
padding-inline: 1.5rem;
}
main.container {
width: 100%;
flex: 1;
padding-block: 3rem 4.5rem;
}
.gate-mark {
color: var(--accent);
vertical-align: -0.2em;
}
/* ── Kicker (shared small-caps style) ────────────────────────── */
.kicker {
font-family: var(--font-sans);
font-weight: 500;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--fg-muted);
}
/* ── Site header ─────────────────────────────────────────────── */
.site-header {
border-bottom: 1px solid var(--border);
padding-block: 1rem;
}
.site-header .container {
display: flex;
align-items: center;
gap: 1.25rem;
}
.site-title {
margin-right: auto;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: inherit;
}
.site-title .gate-mark {
width: 1.15em;
height: 1.15em;
vertical-align: -0.15em;
}
.site-title .wordmark {
font-weight: 600;
letter-spacing: 0.14em;
color: var(--fg);
text-transform: uppercase;
font-size: 0.78rem;
}
.site-title:hover .wordmark { color: var(--accent); }
/* ── Language switcher ───────────────────────────────────────── */
.lang-switcher {
display: flex;
list-style: none;
padding: 0;
margin: 0;
gap: 0.5rem;
align-items: center;
}
.lang-switcher li { display: flex; }
.lang-switcher a {
font-family: var(--font-sans);
font-weight: 500;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--fg-muted);
text-decoration: none;
padding: 0.1rem 0.15rem;
border-bottom: 1.5px solid transparent;
}
.lang-switcher a:hover { color: var(--accent); }
.lang-switcher .is-active a {
color: var(--fg);
border-bottom-color: var(--accent);
}
/* ── Hero (home) ─────────────────────────────────────────────── */
.hero {
padding-block: 3.5rem 2.5rem;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-family: var(--font-sans);
font-weight: 600;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
margin: 0 0 1.75rem;
}
.status-chip::before {
content: "";
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: var(--accent);
}
.status-chip .mono {
font-family: var(--font-mono);
font-weight: 500;
letter-spacing: 0.02em;
text-transform: none;
font-size: 0.78rem;
}
.home h1 {
font-family: var(--font-sans);
font-weight: 800;
font-size: clamp(3.25rem, 10vw, 6.5rem);
line-height: 0.95;
letter-spacing: -0.035em;
margin: 0 0 1.5rem;
color: var(--fg);
}
.home .lede {
font-family: var(--font-sans);
font-weight: 400;
font-size: clamp(1.15rem, 1.8vw, 1.375rem);
line-height: 1.4;
letter-spacing: -0.005em;
color: var(--fg-muted);
margin: 0;
max-width: 36rem;
}
/* ── Body prose ──────────────────────────────────────────────── */
.prose {
max-width: var(--measure);
}
.prose p {
margin: 0 0 1.2rem;
letter-spacing: -0.003em;
}
.prose p:last-child { margin-bottom: 0; }
.prose strong {
font-weight: 600;
color: var(--fg);
}
.prose a {
color: var(--accent);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--accent) 35%, transparent);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
transition: color 120ms, text-decoration-color 120ms;
}
.prose a:hover {
color: var(--accent-hover);
text-decoration-color: var(--accent);
}
.prose em, .prose i { font-style: italic; }
/* ── Footer ──────────────────────────────────────────────────── */
.site-footer {
margin-top: auto;
border-top: 1px solid var(--border);
padding-block: 1.25rem;
}
.site-footer .container {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.site-footer .kicker { margin: 0; }
.site-footer a {
color: var(--fg-muted);
text-decoration: none;
border-bottom: 1px solid transparent;
padding-bottom: 1px;
transition: color 120ms, border-color 120ms;
}
.site-footer a:hover {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ── Selection + focus ───────────────────────────────────────── */
::selection {
background: var(--accent);
color: var(--bg);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
border-radius: 2px;
}

View file

@ -0,0 +1,18 @@
---
title: "Furtka"
description: "Offenes Heimserver-Betriebssystem — einfach genug für alle."
status: "<span class=\"mono\">26.0-alpha</span> — in Arbeit"
---
**Furtka** ist ein offenes Heimserver-Betriebssystem.
USB-Stick einstecken, durch einen Assistenten klicken, und aus jedem
alten x86-PC wird eine private Cloud für den Haushalt — mit eigenen
Apps, eigener Domain, eigenen Daten.
Das Ziel ist einfach: **dein Vater soll das einrichten können.**
Wir sind zu zweit und bauen das öffentlich, abends und am Wochenende.
Es ist früh. Außer uns selbst sollte das noch niemand benutzen.
Sobald es etwas Echtes zu zeigen gibt, steht es hier.
Mitlesen? Schreib an <a href="mailto:hallo@furtka.org">hallo@furtka.org</a>.

18
website/content/_index.md Normal file
View file

@ -0,0 +1,18 @@
---
title: "Furtka"
description: "Open-source home server OS — simple enough for everyone."
status: "<span class=\"mono\">26.0-alpha</span> — work in progress"
---
**Furtka** is an open-source home server OS.
Boot from USB, click through a wizard, and any old x86 PC
turns into a private cloud for your household — with your own apps,
your own domain, your own data.
The goal is simple: **your dad should be able to set this up.**
We're two people building it in public on evenings and weekends,
and it's early. Nothing here is ready for other people yet.
When there's something real to show, this page will say so.
Want to follow along? Write to <a href="mailto:hallo@furtka.org">hallo@furtka.org</a>.

22
website/deploy.sh Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Deploy furtka.org to forge-runner-01.
# Rsyncs the Hugo source up to the VM and builds it in-place.
set -euo pipefail
HOST="${FURTKA_HOST:-forge-runner}"
SRCROOT="/srv/furtka-site"
WEBROOT="/var/www/furtka.org"
HERE="$(cd "$(dirname "$0")" && pwd)"
echo "→ rsync website/ to $HOST:$SRCROOT"
rsync -az --delete \
--exclude='.hugo_build.lock' \
--exclude='public/' \
--exclude='resources/' \
"$HERE/" "$HOST:$SRCROOT/"
echo "→ build on $HOST"
ssh "$HOST" "cd $SRCROOT && hugo --minify --cleanDestinationDir -d $WEBROOT"
echo "OK: deployed to https://furtka.org/"

36
website/hugo.toml Normal file
View file

@ -0,0 +1,36 @@
baseURL = "https://furtka.org/"
title = "Furtka"
defaultContentLanguage = "en"
defaultContentLanguageInSubdir = false
enableRobotsTXT = true
[params]
description = "Open-source home server OS — simple enough for everyone."
version = "26.0-alpha"
contactEmail = "hallo@furtka.org"
[markup.goldmark.renderer]
unsafe = true
[languages]
[languages.en]
languageCode = "en-us"
languageName = "English"
title = "Furtka"
weight = 1
[languages.en.params]
description = "Open-source home server OS — simple enough for everyone."
[languages.de]
languageCode = "de-de"
languageName = "Deutsch"
title = "Furtka"
weight = 2
[languages.de.params]
description = "Offenes Heimserver-Betriebssystem — einfach genug für alle."
[build]
writeStats = true
[minify]
minifyOutput = true

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="{{ .Site.Language.Lang }}">
<head>
{{ partial "head.html" . }}
</head>
<body>
{{ partial "header.html" . }}
<main class="container">
{{ block "main" . }}{{ end }}
</main>
{{ partial "footer.html" . }}
</body>
</html>

View file

@ -0,0 +1,21 @@
{{ define "main" }}
<article>
<header class="page-header">
<h1>{{ .Title }}</h1>
{{ with .Params.description }}<p class="lede">{{ . }}</p>{{ end }}
</header>
<div class="prose{{ if .Params.wide }} prose--wide{{ end }}">
{{ .Content }}
</div>
{{ with .Pages }}
<ul class="post-list">
{{ range . }}
<li>
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
{{ with .Date }}<time datetime="{{ .Format "2006-01-02" }}">{{ .Format "2006-01-02" }}</time>{{ end }}
</li>
{{ end }}
</ul>
{{ end }}
</article>
{{ end }}

View file

@ -0,0 +1,11 @@
{{ define "main" }}
<article>
<header class="page-header">
<h1>{{ .Title }}</h1>
{{ with .Params.description }}<p class="lede">{{ . }}</p>{{ end }}
</header>
<div class="prose{{ if .Params.wide }} prose--wide{{ end }}">
{{ .Content }}
</div>
</article>
{{ end }}

View file

@ -0,0 +1,14 @@
{{ define "main" }}
<article class="home">
<header class="hero">
{{ with .Params.status }}
<p class="status-chip">{{ . | safeHTML }}</p>
{{ end }}
<h1>{{ .Title }}</h1>
{{ with site.Params.description }}<p class="lede">{{ . }}</p>{{ end }}
</header>
<div class="prose">
{{ .Content }}
</div>
</article>
{{ end }}

View file

@ -0,0 +1,9 @@
<footer class="site-footer">
<div class="container">
<p class="kicker">
Furtka <span style="letter-spacing:0.04em">{{ site.Params.version }}</span>
· AGPL-3.0 ·
<a href="mailto:{{ site.Params.contactEmail }}">{{ site.Params.contactEmail }}</a>
</p>
</div>
</footer>

View file

@ -0,0 +1,6 @@
<svg class="gate-mark" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 20 V12 A9 9 0 0 1 20 12 V20"/>
<line x1="12" y1="5" x2="12" y2="20"/>
<line x1="3" y1="20" x2="21" y2="20"/>
<line x1="15" y1="12" x2="15" y2="14.5"/>
</svg>

After

Width:  |  Height:  |  Size: 384 B

View file

@ -0,0 +1,16 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ if .IsHome }}{{ site.Title }} — {{ site.Params.description }}{{ else }}{{ .Title }} · {{ site.Title }}{{ end }}</title>
<meta name="description" content="{{ with .Params.description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<meta property="og:site_name" content="{{ site.Title }}">
<meta property="og:title" content="{{ if .IsHome }}{{ site.Title }}{{ else }}{{ .Title }} · {{ site.Title }}{{ end }}">
<meta property="og:description" content="{{ with .Params.description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}">
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
<meta property="og:url" content="{{ .Permalink }}">
{{ $parts := split .Site.Language.LanguageCode "-" }}<meta property="og:locale" content="{{ index $parts 0 }}{{ if gt (len $parts) 1 }}_{{ upper (index $parts 1) }}{{ end }}">
{{ range .AllTranslations }}
<link rel="alternate" hreflang="{{ .Lang }}" href="{{ .Permalink }}">
{{ end }}
{{ $css := resources.Get "css/main.css" | minify | fingerprint }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}">

View file

@ -0,0 +1,9 @@
<header class="site-header">
<div class="container">
<a href="{{ if eq .Site.Language.Lang "de" }}/de/{{ else }}/{{ end }}" class="site-title" aria-label="Furtka — home">
{{ partial "gate.html" . }}
<span class="wordmark">Furtka</span>
</a>
{{ partial "lang-switcher.html" . }}
</div>
</header>

View file

@ -0,0 +1,23 @@
<ul class="lang-switcher" aria-label="Language">
{{ $current := .Site.Language.Lang }}
{{ range site.Languages }}
{{ $lang := . }}
{{ $link := "" }}
{{ if eq .Lang $current }}
{{ $link = $.Permalink }}
{{ else }}
{{ with (where $.AllTranslations "Lang" .Lang) }}
{{ $link = (index . 0).Permalink }}
{{ else }}
{{ if eq $lang.Lang "en" }}
{{ $link = "/" }}
{{ else }}
{{ $link = printf "/%s/" $lang.Lang }}
{{ end }}
{{ end }}
{{ end }}
<li{{ if eq .Lang $current }} class="is-active"{{ end }}>
<a href="{{ $link }}" lang="{{ .Lang }}"{{ if eq .Lang $current }} aria-current="true"{{ end }}>{{ upper .Lang }}</a>
</li>
{{ end }}
</ul>

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#a33e2e" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 20 V12 A9 9 0 0 1 20 12 V20"/>
<line x1="12" y1="5" x2="12" y2="20"/>
<line x1="3" y1="20" x2="21" y2="20"/>
<line x1="15" y1="12" x2="15" y2="14.5"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B