fundbureau: single-page site + Docker/Proxmox deploy
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
public/
|
||||||
|
resources/
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/public/
|
||||||
|
/resources/
|
||||||
|
.hugo_build.lock
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# ── build the static site with Hugo (extended) ───────────────────────────
|
||||||
|
FROM hugomods/hugo:exts AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
RUN hugo --gc --minify
|
||||||
|
|
||||||
|
# ── serve the static output with nginx ────────────────────────────────────
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /src/public /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# fundbureau.xyz
|
||||||
|
|
||||||
|
Single-page Hugo site for **FUND BUREAU** — a collective of architects based in
|
||||||
|
Lucerne. Static, no trackers, no third-party services.
|
||||||
|
|
||||||
|
- Build: `hugo` (extended)
|
||||||
|
- Local dev: `hugo server -D`
|
||||||
|
- Content & config: `hugo.toml` (wordmark, intro, address, image, footer credit)
|
||||||
|
- Image: `static/img/hero.webp` (1:1)
|
||||||
|
- Type: Manrope reference; current build uses Neue Haas Grotesk with a Helvetica
|
||||||
|
fallback — drop the woff2 into `static/fonts/` to enable it.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Docker (nginx) in an LXC on Proxmox, Caddy in front for TLS. See [`deploy/`](deploy/).
|
||||||
|
|
||||||
|
Built with openbureau.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/* ---------------------------------------------------------------
|
||||||
|
FUND BUREAU — minimal frontpage
|
||||||
|
typography: Neue Haas Grotesk (lizenzpflichtig). lege deine
|
||||||
|
woff2-dateien in static/fonts/ ab, dann greifen die @font-face
|
||||||
|
regeln unten. bis dahin: klassischer Helvetica-fallback.
|
||||||
|
--------------------------------------------------------------- */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Neue Haas Grotesk";
|
||||||
|
src: url("/fonts/neue-haas-grotesk-display-55-roman.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Neue Haas Grotesk";
|
||||||
|
src: url("/fonts/neue-haas-grotesk-display-65-medium.woff2") format("woff2");
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ink: #111;
|
||||||
|
--paper: #fff;
|
||||||
|
--gap: clamp(1.25rem, 4vw, 2.5rem);
|
||||||
|
/* gemeinsame spaltenbreite: titel, bild und adresse fluchten links */
|
||||||
|
--media: min(100%, 56vh, 560px);
|
||||||
|
--grotesk: "Neue Haas Grotesk", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: var(--gap);
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
gap: var(--gap);
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--grotesk);
|
||||||
|
font-weight: 400;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img { -webkit-user-drag: none; user-drag: none; pointer-events: none; }
|
||||||
|
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; text-underline-offset: 0.15em; }
|
||||||
|
|
||||||
|
/* wordmark, oben — linke kante auf der bildkante ------------------------- */
|
||||||
|
.wordmark {
|
||||||
|
width: var(--media);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
.wordmark a {
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: clamp(1.5rem, 4.5vw, 3rem);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.wordmark a:hover { text-decoration: none; }
|
||||||
|
|
||||||
|
/* mitte: quadratisches bild + adresse ------------------------------------ */
|
||||||
|
.stage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
margin: 0;
|
||||||
|
width: var(--media);
|
||||||
|
aspect-ratio: 1 / 1; /* quadratische box erzwingen */
|
||||||
|
}
|
||||||
|
.hero img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover; /* bild füllt die box, wird beschnitten */
|
||||||
|
background: #ececec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* intro + adresse, unter dem bild ---------------------------------------- */
|
||||||
|
.colophon {
|
||||||
|
width: var(--media);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4em var(--gap);
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: clamp(0.85rem, 1.6vw, 1rem);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.colophon .intro { margin: 0; max-width: 22ch; }
|
||||||
|
|
||||||
|
.colophon address {
|
||||||
|
font-style: normal;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.colophon address span,
|
||||||
|
.colophon address a { display: block; }
|
||||||
|
|
||||||
|
/* fuss ------------------------------------------------------------------- */
|
||||||
|
.credit {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
opacity: 0.6;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
.credit p { margin: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.colophon { flex-direction: column; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Caddy site block for fundbureau. Replace the upstream IP with the container's
|
||||||
|
# IP, reload caddy (`caddy reload` / `systemctl reload caddy`), verify, then
|
||||||
|
# retire any old container. Keeping the domain here makes a cutover a one-line
|
||||||
|
# upstream change.
|
||||||
|
|
||||||
|
fundbureau.xyz, www.fundbureau.xyz {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy CHANGE_ME_CONTAINER_IP:8080
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Deploy
|
||||||
|
|
||||||
|
Static Hugo build served by nginx in one container
|
||||||
|
(`docker compose up -d --build`). Hosting: an LXC on Proxmox, with Caddy in
|
||||||
|
front terminating TLS and reverse-proxying `fundbureau.xyz`.
|
||||||
|
|
||||||
|
## Provision
|
||||||
|
|
||||||
|
On the Proxmox node, with a free CTID and a free IP:
|
||||||
|
|
||||||
|
```
|
||||||
|
CTID=142 IP=10.0.0.51/24 GW=10.0.0.1 BRIDGE=vmbr0 STORAGE=local-lvm \
|
||||||
|
REPO=https://github.com/<user>/fundbureau.git \
|
||||||
|
./provision-lxc.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
1. Test it: `curl -I http://10.0.0.51:8080`
|
||||||
|
2. Point Caddy's upstream at the IP (`deploy/Caddyfile`), reload Caddy.
|
||||||
|
3. Open `https://fundbureau.xyz`, verify.
|
||||||
|
|
||||||
|
## Update later
|
||||||
|
|
||||||
|
```
|
||||||
|
pct exec <CTID> -- bash -lc 'cd /opt/fundbureau && git pull && docker compose up -d --build'
|
||||||
|
```
|
||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Provision an LXC for the fundbureau website (Docker + compose). RUN ON THE PROXMOX NODE.
|
||||||
|
#
|
||||||
|
# Create this container on a FREE CTID and a free IP, test it, then point Caddy's
|
||||||
|
# upstream at it. Override any value via env, e.g.:
|
||||||
|
# CTID=142 IP=10.0.0.51/24 GW=10.0.0.1 BRIDGE=vmbr0 STORAGE=local-lvm ./provision-lxc.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CTID="${CTID:-142}" # must be free: pct status $CTID → should error
|
||||||
|
HOSTNAME_="${HOSTNAME_:-fundbureau}"
|
||||||
|
IP="${IP:-CHANGE_ME/24}" # free ip in your bridge subnet
|
||||||
|
GW="${GW:-CHANGE_ME}"
|
||||||
|
BRIDGE="${BRIDGE:-vmbr0}"
|
||||||
|
STORAGE="${STORAGE:-local-lvm}"
|
||||||
|
TEMPLATE="${TEMPLATE:-local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst}"
|
||||||
|
REPO="${REPO:-https://github.com/CHANGE_ME/fundbureau.git}"
|
||||||
|
DISK="${DISK:-4}"; CORES="${CORES:-1}"; RAM="${RAM:-512}"
|
||||||
|
|
||||||
|
echo "Creating LXC $CTID ($HOSTNAME_) on $IP ..."
|
||||||
|
pct create "$CTID" "$TEMPLATE" \
|
||||||
|
--hostname "$HOSTNAME_" --cores "$CORES" --memory "$RAM" \
|
||||||
|
--rootfs "${STORAGE}:${DISK}" \
|
||||||
|
--net0 "name=eth0,bridge=${BRIDGE},ip=${IP},gw=${GW}" \
|
||||||
|
--features nesting=1 --unprivileged 1 --onboot 1
|
||||||
|
pct start "$CTID"
|
||||||
|
sleep 6
|
||||||
|
|
||||||
|
pct exec "$CTID" -- bash -lc '
|
||||||
|
set -e
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update && apt-get install -y ca-certificates curl git
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release; echo $VERSION_CODENAME) stable" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list
|
||||||
|
apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||||
|
rm -rf /opt/fundbureau && git clone '"$REPO"' /opt/fundbureau
|
||||||
|
cd /opt/fundbureau && docker compose up -d --build
|
||||||
|
'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "done. test the new container: curl -I http://${IP%/*}:8080"
|
||||||
|
echo "then point Caddy at ${IP%/*}:8080 (see deploy/Caddyfile), reload caddy,"
|
||||||
|
echo "verify the domain."
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
image: fundbureau
|
||||||
|
container_name: fundbureau
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
baseURL = "https://fundbureau.xyz/"
|
||||||
|
languageCode = "de-CH"
|
||||||
|
title = "FUND BUREAU"
|
||||||
|
enableEmoji = true
|
||||||
|
enableRobotsTXT = true
|
||||||
|
|
||||||
|
[params]
|
||||||
|
# wordmark, oben im schwarzen feld
|
||||||
|
name = "FUND BUREAU"
|
||||||
|
# grosser einleitungssatz
|
||||||
|
intro = "a collective of architects<br>based in lucerne."
|
||||||
|
email = "hi@fundbureau.xyz"
|
||||||
|
instagram = "https://instagram.com/fundbureau"
|
||||||
|
|
||||||
|
# adresse
|
||||||
|
[params.address]
|
||||||
|
legal = "FUND BUREAU KlG"
|
||||||
|
street = "Fluhmühlerain 1"
|
||||||
|
city = "6015 Luzern"
|
||||||
|
|
||||||
|
# quadratisches bild (1:1, schon zugeschnitten)
|
||||||
|
[params.hero]
|
||||||
|
image = "/img/hero.webp"
|
||||||
|
alt = "a woman and a child, from afar"
|
||||||
|
|
||||||
|
# fuss-credit, links unten
|
||||||
|
[params.credit]
|
||||||
|
text = "built with openbureau"
|
||||||
|
url = "https://kgva.ch"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{{ define "bodyclass" }}notfound{{ end }}
|
||||||
|
{{ define "main" }}
|
||||||
|
<header class="wordmark"><a href="{{ "/" | relURL }}">{{ .Site.Params.name }}</a></header>
|
||||||
|
<main class="stage">
|
||||||
|
<p class="intro">404 — not found</p>
|
||||||
|
<p><a href="{{ "/" | relURL }}">← back</a></p>
|
||||||
|
</main>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="{{ .Site.Language.Lang | default "de" }}" dir="ltr">
|
||||||
|
{{ partial "head.html" . }}
|
||||||
|
<body class="{{ block "bodyclass" . }}{{ end }}">
|
||||||
|
{{ block "main" . }}{{ end }}
|
||||||
|
<script>
|
||||||
|
// kein kontextmenü, keine selektion, kein bild-drag
|
||||||
|
['contextmenu', 'selectstart', 'dragstart'].forEach(function (ev) {
|
||||||
|
document.addEventListener(ev, function (e) { e.preventDefault(); });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{ define "bodyclass" }}home{{ end }}
|
||||||
|
{{ define "main" }}
|
||||||
|
{{ $a := .Site.Params.address }}
|
||||||
|
<header class="wordmark">
|
||||||
|
<a href="{{ "/" | relURL }}">{{ .Site.Params.name }}</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="stage">
|
||||||
|
{{ with .Site.Params.hero.image }}
|
||||||
|
<figure class="hero">
|
||||||
|
<img src="{{ . | relURL }}" alt="{{ $.Site.Params.hero.alt }}" width="774" height="774">
|
||||||
|
</figure>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<section class="colophon">
|
||||||
|
{{ with .Site.Params.intro }}<p class="intro">{{ . | safeHTML }}</p>{{ end }}
|
||||||
|
<address>
|
||||||
|
{{ with $a.legal }}<span>{{ . }}</span>{{ end }}
|
||||||
|
{{ with $a.street }}<span>{{ . }}</span>{{ end }}
|
||||||
|
{{ with $a.city }}<span>{{ . }}</span>{{ end }}
|
||||||
|
{{ with .Site.Params.email }}<a href="mailto:{{ . }}">{{ . }}</a>{{ end }}
|
||||||
|
{{ with .Site.Params.instagram }}<a href="{{ . }}" rel="me noopener" target="_blank">instagram</a>{{ end }}
|
||||||
|
</address>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{{ with .Site.Params.credit }}
|
||||||
|
<footer class="credit">
|
||||||
|
<p><a href="{{ .url }}" rel="noopener" target="_blank">{{ .text }}</a></p>
|
||||||
|
</footer>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ .Site.Title }}</title>
|
||||||
|
<meta name="description" content="{{ .Site.Params.intro }}">
|
||||||
|
|
||||||
|
<meta property="og:title" content="{{ .Site.Title }}">
|
||||||
|
<meta property="og:description" content="{{ .Site.Params.intro }}">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
{{ with .Site.Params.hero.image }}<meta property="og:image" content="{{ . | absURL }}">{{ end }}
|
||||||
|
|
||||||
|
<link rel="icon" href="data:,">
|
||||||
|
{{ $css := resources.Get "css/main.css" | minify | fingerprint }}
|
||||||
|
<link rel="stylesheet" href="{{ $css.RelPermalink }}">
|
||||||
|
</head>
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 404 /404.html;
|
||||||
|
|
||||||
|
# long cache for fingerprinted/static assets
|
||||||
|
location ~* \.(css|js|woff2|webp|svg|png|jpe?g|ico)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800";
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
Reference in New Issue
Block a user