Two halves of the same complaint: the journey into the members area does not feel like one platform. Members sign in to /comunidad/ through Gitea, so Gitea's sign-in form and authorize dialog are part of everyone's journey, not just of anyone who opens a repository. That is what the earlier "is Gitea only for admins?" question got wrong. Unthemed they are two dark screens in the middle of a cream site. Unlike Decap, Gitea supports being themed, so this needs no forking and nothing a release can silently undo. The repository holds only the colour overrides; scripts/gitea-theme.sh concatenates them onto the light theme it reads out of the running container. Keeping somebody else's stylesheet in here would go stale and turn every Gitea upgrade into a merge — the script is re-run instead, which is one line in the upgrade notes. Getting a variable name wrong leaves a corner grey rather than breaking a page, which is the failure mode worth having when the names belong to someone else's project. The logo is the site's wordmark, as live text in the same font stack rather than traced to paths: the site loads no webfont at all, deliberately, so visitors already see it in whatever sans-serif their machine substitutes. Paths would render a Montserrat most people never see on the site itself. Salir was the sharper problem. It cleared this session and the stored token, and then said "Sesión cerrada" — while the Gitea session in the same browser stayed open and Gitea still remembered the authorisation, so one click signed the next person straight back in with no password. On a laptop shared around an association, that button was lying. It cannot be fixed from here. Gitea's logout has been POST-only since 1.11.2, so a link cannot trigger it and a cross-site POST needs a CSRF token this app does not have; prompt=login is undocumented in every released version of Gitea's OAuth2 provider, and a security control should not rest on that. So the logout page now says exactly what is closed and what is not, and links to the one place that finishes it. Being honest about a limit beats a reassuring message that is false. Verified: 101 checks, two of them new — logout warns rather than redirecting, and leaves no token the server could still act with. The theme itself is visual and has to be looked at; docs/server-setup.md §12 says where. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn |
||
|---|---|---|
| apps/board | ||
| content | ||
| docker | ||
| docs | ||
| infra | ||
| scripts | ||
| static | ||
| themes/vienalatina | ||
| .gitignore | ||
| .woodpecker.yml | ||
| config.yaml | ||
| README.md | ||
Viena Latina
Self-hosted, trilingual site for the Latin American community in Vienna — Hugo + Decap CMS + Gitea + Woodpecker CI on a single Hetzner CX22 (Falkenstein, DE). Replaces the previous WordPress + Polylang + synchronous DeepL stack.
Translation is self-hosted too: OPUS-MT (CC-BY-4.0) runs on CPU via CTranslate2 in the pipeline image, with the models mounted from the host. No API key, no quota, and no visitor or content data leaving the server.
Pablo ──► Decap CMS (/admin) ──commit──► Gitea ──webhook──► Woodpecker CI
▲ │
│ translate ──────┤
OAuth2 build (hugo) ─┤
│ deploy (rsync) ─┘
│ ▼
Members ──► /comunidad/ ────────────────┘ Caddy 2 serves /var/www/vienalatina.com
Flask + SQLite …and proxies /comunidad/ to Flask
Everything except /comunidad/ is a static file built from git. The members
area is the one component that runs code to answer a request, and the one whose
data is not reproducible from the repository — see Members area below.
Languages
Spanish, German (/de/) and Brazilian Portuguese (/pt-br/). Any of the
three can be the authored original; the other two are generated by CI as
reviewable git commits. Siblings pair by filename basename:
content/post/mi-articulo.es.md ← authored (no `translated_from`)
content/post/mi-articulo.de.md ← written by scripts/translate.py
content/post/mi-articulo.pt-br.md ← written by scripts/translate.py
A generated file carries translated_from, which is what stops CI translating
its own output back into a loop. Machine output is never treated as a source.
manual_translation: true means "hands off", on both sides:
- on an authored original — don't generate siblings for this post at all
- on a generated sibling — never overwrite it again
Frontmatter contract
---
title: "Mi artículo"
date: 2026-07-31
lang: es
manual_translation: false
categories: [Gastronomía] # taxonomy terms stay Spanish in every language
---
Generated siblings additionally carry translated_from: es.
Translation flow
- Publish in Decap → one git commit.
- Gitea webhook fires Woodpecker.
scripts/translate.pydiffs the push, finds changed authored files in any language, generates the missing siblings, and pushes them back as a bot commit (translations@vienalatina.com) marked[skip-translate].hugo --minifybuilds,rsync --deletedeploys, Caddy serves.
Markup never reaches the model: code blocks and raw HTML pass through
untouched, and link targets, inline code and protected community terms
(Grätzl, Naschmarkt, …) are masked and verified to survive the round trip.
Small models drop those masks occasionally, so a failed round trip degrades in steps rather than failing the publish:
- mask markup and protected terms — the normal path;
- if a term is lost, retry guarding only markup, and log that the term may now be translated;
- if markup itself is lost, leave that segment in the source language and warn.
A stray ⦅0⦆ or a mangled URL therefore never reaches a reader, and one
awkward proper noun never blocks a deploy.
Backfill anything missing siblings (after the WP migration, or for pages that predate the pipeline):
python scripts/translate.py --backfill
Translation engine
Built once on the server, and again only when changing models:
docker build -t vienalatina/translate:1 docker/translate
The image bakes in a pre-converted CTranslate2 build of M2M100 418M plus its
tokenizer, so a publish makes no network calls. Tunable via MT_MODEL_DIR,
MT_TOKENIZER, MT_COMPUTE_TYPE and MT_THREADS.
Swapping engines means writing one class against
scripts/translation/provider.py — nothing in the pipeline changes. Two
upgrades worth knowing about:
- M2M100 1.2B — same MIT licence and same code path, materially better output, but ~2–2.5GB peak RAM. Needs an 8GB box (Hetzner CX32), not the CX22.
- opus-mt-tc-big — better still for these specific language pairs and the
only permissive option that handles Brazilian Portuguese distinctly (
>>pob<<), at the cost of CC-BY-4.0 attribution and one model per directed pair.
Do not build on NLLB-200 (CC-BY-NC, non-commercial) or LibreTranslate (AGPL-3.0) if this stack is ever to be sold or offered as a service.
Server setup
Full command-by-command walkthrough: docs/server-setup.md. The Caddyfile and Docker Compose files it uses live in infra/.
Local development
hugo server # http://localhost:1313
CI secrets (Woodpecker → repo settings → secrets)
| Secret | Value |
|---|---|
gitea_push_token |
Gitea token for the translations bot user, repo write access |
Translation needs no secret — the model is local.
The repo must be marked trusted in Woodpecker so the deploy step can
mount /var/www/vienalatina.com.
Decap CMS
static/admin/config.yml uses the Gitea backend. Register an OAuth app in
Gitea admin (redirect URI https://vienalatina.com/admin/) and put its
client ID in app_id. The Decap JS bundle is downloaded at build time into
static/admin/decap-cms.js (gitignored) — zero third-party requests at
runtime.
Members area
apps/board/ — a small Flask app at /comunidad/, behind Caddy, holding roles
and an internal message board. Signed-in members only.
apps/board/
app.py factory, config, the CSRF and noindex hooks
auth.py Gitea OAuth2 (confidential client) and the membership gate
members.py roles, provisioning, ownership transfer, GDPR erasure/export
board.py threads and comments
content.py the editor — writes posts to Gitea's contents API
gitea.py the only module that talks to Gitea
tokens.py per-member access tokens, refreshed before they expire
render.py markdown with raw HTML disabled
schema.sql the tables, including the one-owner index
tests/ pytest, 97 checks — `python3 -m pytest apps/board/tests`
Three things about it are load-bearing and easy to undo by accident:
- A Gitea account is not a membership. Login succeeds only for an active row
in
members. Drop that check and every account on the instance gets in, starting with the translations bot. - One owner, enforced by a partial unique index, not by application code. The owner cannot be suspended or demoted by anyone; stepping down means transferring ownership to an admin.
html=Falseinrender.pyis the entire XSS defence, and it works because markdown-it then emits only its own tags. Turning it on means owning a sanitiser allowlist forever.
Admins can delete anyone's post; nobody can edit anyone else's, admins included. Removing a post is visible to its author, quietly rewriting it is not.
Its SQLite database is the only state on the server that git does not hold.
scripts/backup-board.sh takes a consistent snapshot nightly — see
docs/server-setup.md §11.
Writing posts
/comunidad/contenido/ replaces Decap CMS for admins: a form that commits a
file through Gitea's contents API, so Woodpecker sees an ordinary push and
translate → build → deploy runs unchanged. Commits carry the author's own
account, not a bot's.
It exists because Decap has no supported way to be themed — its maintainer's
answer is override its CSS and accept that class names move, or fork it — so
/admin/ would always look like a different product bolted on.
Decap is still running. Both editors write the same files, and the pipeline
cannot tell them apart, so there is no cutover to get wrong. docs/server-setup.md
§11.8 covers removing Decap when the new editor has earned it.
Filenames are the contract, not a detail: YYYY-MM-DD-slug.es.md is what
split_lang() parses and what keeps two posts off one URL. The tests import
scripts/translate.py and run its parser over what the editor writes, because
a file it cannot parse publishes in Spanish and is never translated, silently.
GEO/SEO surfaces
hreflang+og:locale(:alternate)+ JSON-LDBlogPosting/BlogwithinLanguage—themes/vienalatina/layouts/partials/seo-head.htmlrobots.txtAI-crawler allowlist —static/robots.txtllms.txt— generated at build time fromlayouts/index.llms.txtsitemap.xml— Hugo native, multilingual
One-shot content migration
pip install requests html2text
python scripts/wp-to-hugo.py https://<old-wp-site>
Converts every WP post (with Polylang siblings) to
content/post/<slug>.<lang>.md, downloads images into static/uploads/,
and preserves existing slugs. Spot-check ~10 articles before committing.
Add Caddy 301s for WP URL patterns that don't map cleanly
(?p=123, /categoria/... → /categories/...).