vienalatina/docs/server-setup.md
Claude b90cbd2a9f
One front door: the members area owns its own logins
Three complaints, one cause. Logging out could not finish because the
session belonged to another server, whose logout is POST-only and
unreachable from here. "Ese usuario ya está en uso" for somebody absent
from Miembros, because erase_member deleted our row and left the git
account standing — two stores of users, one of them showing. And the
hand-off to a differently-designed domain, with a Forgot password that
could never work. All three followed from delegating identity, so it is
no longer delegated.

Members now sign in at /comunidad/login against a scrypt hash in our own
database, via werkzeug.security, which arrives with Flask. They have no
account on the git server at all, which makes the collision impossible
rather than fixed. Logout is one click.

This removes more than it adds: the OAuth round trip, the client
registration, tokens.py with its refresh-before-expiry logic, the
gitea_tokens table, and the logged-out page that existed to apologise
for a logout that did not log you out.

The editor keeps per-writer attribution without per-writer tokens: one
CONTENT_TOKEN commits, and each commit names its author, which Gitea's
contents API supports and a test now asserts. CONTENT_TOKEN falls back
to GITEA_ADMIN_TOKEN so nothing breaks on deploy, but it only needs
write access to one repository, while the admin token can modify every
account on the instance — and now has no remaining job.

The refusals are the interesting part. Unknown name, wrong password,
suspended member and invited-but-never-arrived all answer identically,
asserted by comparing the rendered bytes. The hash check runs against a
decoy even when there is no such member, so an unknown name does not
answer faster. Attempts are rate limited, counted inside SQLite for the
reason invites.py documents. A NULL hash never matches anything.

Migration 2 adds password_hash and drops the dead OAuth tokens. Everyone
starts NULL, including the owner, so scripts/set-password.sh exists and
was tested before this could ship: it prompts without echo, never takes
the password as an argument where ps would show it, and refuses a short
one or an unknown member.

Rehearsed against a rebuilt copy of the server's database: starts, keeps
the photo, drops the tokens table, serves a login form with no redirect,
signs in, signs out, stays out.

223 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
2026-09-29 08:25:25 +00:00

37 KiB
Raw Blame History

Server setup — step by step (Hetzner CX22, Ubuntu 24.04)

Every command you need, in order. Commands prefixed local$ run on your own computer; everything else runs on the server over SSH. Budget ~2–3 hours.

Where you see pablo, <SERVER-IP>, or a password placeholder, substitute your own values.


0. Order the server

At console.hetzner.com → Create Server:

  • Location: Nuremberg
  • Image: Ubuntu 24.04
  • Type: Shared vCPU → CX22 (2 vCPU, 4 GB RAM, €4.51/mo)
  • SSH key: add your public key (local$ cat ~/.ssh/id_ed25519.pub — if you don't have one: local$ ssh-keygen -t ed25519). Adding it here means root login works by key from the start, no password emails.

Note the server's IP address — that's <SERVER-IP> everywhere below.

1. DNS (do this first — it needs time to propagate)

At your domain registrar, add two A records:

Name Type Value TTL
git A <SERVER-IP> 300
ci A <SERVER-IP> 300

Do NOT touch the record for vienalatina.com itself — the live WordPress site keeps running until cutover day.

Check propagation (repeat until it prints the server IP):

local$ dig +short git.vienalatina.com

2. First login + basic hardening

local$ ssh root@<SERVER-IP>

Update and create your user:

apt update && apt -y upgrade

adduser pablo                 # pick a strong password, skip the questions
usermod -aG sudo pablo

# give your user the same SSH key root has
rsync --archive --chown=pablo:pablo ~/.ssh /home/pablo

Lock SSH down to keys only:

sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
systemctl restart ssh

Before closing this terminal, open a second one and confirm you can get in:

local$ ssh pablo@<SERVER-IP>

From here on, work as pablo and prefix privileged commands with sudo (or run sudo -i once).

Firewall + brute-force protection:

sudo apt install -y ufw fail2ban
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
sudo systemctl enable --now fail2ban

3. Install Caddy

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy

4. Install Docker

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker pablo

Log out and back in (exit, then ssh pablo@<SERVER-IP>) so the docker group takes effect. Verify: docker ps should print an empty table, not a permission error.

5. Get this repo's infra files onto the server

sudo mkdir -p /srv /var/www/vienalatina.com
cd ~
git clone https://github.com/pablovolenski/vienalatina.git
sudo cp -r vienalatina/infra/gitea /srv/gitea
sudo cp -r vienalatina/infra/woodpecker /srv/woodpecker
sudo cp vienalatina/infra/caddy/Caddyfile /etc/caddy/Caddyfile
sudo systemctl reload caddy

6. Bring up Gitea

cd /srv/gitea
sudo docker compose up -d

Wait ~30 s, then open https://git.vienalatina.com in your browser (the certificate is fetched automatically; if you get an error, wait a minute for DNS/certificate and reload). You'll see Gitea's install page:

  • Database: SQLite3 (fine at this scale)
  • Server domain / base URL: leave as pre-filled (git.vienalatina.com)
  • Administrator account (bottom of the page — expand it): username pablo, your email, a strong password. Create it now; the first account is the admin.
  • Click Install Gitea.

Then create the two OAuth apps and the bot user, all in the Gitea web UI:

  1. Woodpecker OAuth app: profile icon → Site Administration → Integrations → Applications → Create new OAuth2 application
    • Name: woodpecker
    • Redirect URI: https://ci.vienalatina.com/authorize
    • Save the Client ID and Client Secret — needed in step 7.
  2. Decap OAuth app: same screen, second application
    • Name: decap-cms
    • Redirect URI: https://vienalatina.com/admin/ (exactly, trailing slash included — Gitea matches it literally)
    • Untick "Confidential Client". Decap runs in the browser and authenticates with PKCE; a confidential app makes Gitea demand a client secret that a browser cannot keep, and the login fails after you authorize, which makes it look like a Decap bug.
    • Save the Client ID — it goes into static/admin/config.yml (step 9). There is no secret to save, and the Client ID is not one either: it is published in the site's JavaScript by design.
  3. Translations bot: Site Administration → Identity & Access → User Accounts → Create User Account
    • Username: translations, email: translations@vienalatina.com, any strong password.
    • Log in as the bot user once (private window), go to Settings → Applications → Generate New Token, scopes: repository (write). Save the token — it becomes the gitea_push_token secret in step 8.

7. Bring up Woodpecker

cd /srv/woodpecker
sudo cp .env.example .env
openssl rand -hex 32        # copy the output
sudo nano .env              # paste Woodpecker OAuth client ID + secret + the random hex
sudo mkdir -p data && sudo chown -R 1000:1000 data   # v3 images run as uid 1000
sudo docker compose up -d

Open https://ci.vienalatina.com → Login → it bounces you to Gitea → Authorize. You're in, as admin (the WOODPECKER_ADMIN=pablo line in the compose file — edit it if your Gitea username differs).

8. Create the site repo and wire the pipeline

Create the repo in Gitea: + (top right) → New Repository → name vienalatina, owner pablo, not initialized with anything → Create.

Add the translations bot as collaborator: repo → Settings → Collaborators → add translations with Write access.

Push the code into Gitea (from the server clone you made in step 5, or from your laptop):

cd ~/vienalatina
git remote add gitea https://git.vienalatina.com/pablo/vienalatina.git
git push gitea main

# Name the GitHub remote too, while you are here. The clone in step 5 called it
# `origin`, but `main` came to track `gitea/main` — so a bare `git pull` asks
# Gitea, and new work arrives on GitHub. Having both named saves you from typing
# the URL every time you deploy.
git remote add github https://github.com/pablovolenski/vienalatina.git
git remote -v

(Gitea will ask for your Gitea username/password.)

In Woodpecker (https://ci.vienalatina.com):

  1. Repositories → Add repository → enable pablo/vienalatina (this auto-creates the push webhook in Gitea).
  2. Repo → Settings → Project settings → check Trusted (needed so the deploy step may mount /var/www/vienalatina.com).
  3. Repo → Settings → Secrets → add gitea_push_token, the bot token from step 6.3. That is the only secret — translation runs locally and needs no key.

Build the translation image before the first run (~5 minutes; it downloads about 1GB of model):

cd ~/vienalatina
docker build -t vienalatina/translate:1 docker/translate

The push in the step above has already triggered a first pipeline — it ran before the image and secret existed, so push a new commit rather than using Restart. Restart replays the old commit, and a restart's empty diff range makes the translate step find nothing to do. All three steps (translate → build → deploy) should go green, and /var/www/vienalatina.com/ on the server now contains the built site:

ls /var/www/vienalatina.com     # index.html, de/, pt-br/, robots.txt, llms.txt …

9. Point Decap at Gitea

On your working copy: edit static/admin/config.yml, replace REPLACE_WITH_GITEA_OAUTH_CLIENT_ID with the Decap OAuth Client ID from step 6.2, commit, push to Gitea. (You can't log into /admin until the main domain is live — that's expected.)

cd ~/vienalatina
sed -i 's/REPLACE_WITH_GITEA_OAUTH_CLIENT_ID/<client id>/' static/admin/config.yml
grep app_id static/admin/config.yml
git commit -am "Wire Decap to the Gitea OAuth app" && git push gitea main

This value is per-deployment: the placeholder is what belongs in the repo, so leave it in place in any copy of this platform that is not this server.

If /admin/ still shows Client ID not registered afterwards, the page is serving a cached config.yml — hard-reload it. If it fails after the Gitea authorize screen instead, the app was created as a confidential client; delete it and recreate it with that box unticked.

10. Test the translation loop end-to-end

cd ~/vienalatina
cat > content/post/mi-test.es.md <<'EOF'
---
title: "Artículo de prueba"
date: 2026-08-01
lang: es
manual_translation: false
categories: [Comunidad]
---

Esto es una prueba del flujo de traducción automática en el Grätzl.
EOF
git add . && git commit -m "test: pipeline round-trip" && git push gitea main

Within ~60 s the Woodpecker pipeline should finish and content/post/mi-test.de.md + content/post/mi-test.pt-br.md appear in the Gitea repo as commits by translations. Note "Grätzl" survives untranslated (protected term). Delete all three test files with another commit when done.

Saturday complete. 🎉


Cutover day (Sunday evening)

  1. Run the content migration and push (see README, "One-shot content migration"). Migrated Polylang siblings arrive frozen (manual_translation: true) because they are human translations — the machine engine must never overwrite them. Then python scripts/translate.py --backfill fills in any set that WordPress had no translation for. Spot-check the built site with grep on /var/www/vienalatina.com/index.html; the curl -H "Host: vienalatina.com" http://127.0.0.1/ trick only works after step 3, since Caddy has no matching site block until then.
  2. At the registrar: lower the vienalatina.com A record TTL to 300, wait for the old TTL to expire, then change the A record to <SERVER-IP> (and www too, as CNAME to vienalatina.com or A to the same IP).
  3. On the server: uncomment the vienalatina.com blocks in /etc/caddy/Caddyfile, then sudo systemctl reload caddy. Caddy fetches the certificate as soon as DNS resolves to this server.
  4. Verify: the checklist in the migration plan (hreflang tags, robots.txt, llms.txt, Lighthouse, manual_translation: true freeze test, and the loop-prevention test — after the bot pushes siblings, the pipeline it triggers must report "nothing to translate" rather than translating the siblings back).
  5. Keep the WP host untouched for 30 days as fallback; watch Google Search Console and add Caddy 301s for any 404s it reports.

Optional: nightly backups (restic → Hetzner Storage Box)

sudo apt install -y restic
sudo restic -r sftp:uXXXXXX@uXXXXXX.your-storagebox.de:backups init
# then a root cron entry, e.g.:
# 0 3 * * * restic -r sftp:... backup /srv /var/www --password-file /root/.restic-pw

11. Members area (/comunidad/)

The private area: roles and an internal board. It is the only part of the site that runs code to answer a request, and the only data on the server that is not already in git.

11.1 No OAuth application, and no accounts for members

Members sign in on vienalatina.com, against a password stored here. They have no account on the git server at all. If you are reading an older copy of this file: it described registering an OAuth application and handing members to git.vienalatina.com to type their password. That is gone, along with every problem it caused — the hand-off to a differently-designed domain, a Forgot password? that could never work, and a Salir that could not finish because the session belonged to a server we could not reach.

What is left of the git server, as far as members are concerned, is nothing. It stores the site's content. One token lets the editor commit there (CONTENT_TOKEN, §11.2), and only pablo and the pipeline bots have logins.

Passwords are scrypt hashes via werkzeug.security, which arrives with Flask. Nobody — not an admin, not the server — ever sees a member's password: a new member's password_hash is NULL until they choose one through the invitation link, and a NULL hash cannot be signed in with.

If a member already had a git-server account from the old flow, it is now an orphan. Delete those at git.vienalatina.com/-/admin/users, keeping only pablo and the bots. Nothing here reads them any more.

11.2 The token the editor commits with

The members area needs one credential on the git server: something that can write to the site repository when somebody publishes a post.

Gitea → as pablo → Settings → Applications → Generate New Token, scope repository: Read and Write. Put it in /srv/board/.env as CONTENT_TOKEN.

CONTENT_TOKEN=

It falls back to GITEA_ADMIN_TOKEN if left empty, so an existing install keeps working — but they should not stay the same. The admin token can create and modify every account on the instance; publishing a post needs one repository. Since members no longer have accounts to create, the admin token has no remaining job and can be revoked once CONTENT_TOKEN is in place.

Commits still say who wrote them. One token does the committing, and each commit names its author, so git log shows the member and there is somebody to ask about a page a year from now.

11.3 Build and run

cd ~/vienalatina
docker build -t vienalatina/board:1 -f docker/board/Dockerfile .

sudo mkdir -p /srv/board/data
sudo cp -r infra/board/. /srv/board/
cd /srv/board
sudo cp .env.example .env
openssl rand -hex 32          # paste as BOARD_SECRET_KEY
sudo nano .env                # client id, secret, BOARD_OWNER, optional admin token
sudo chown -R 1000:1000 /srv/board/data
sudo docker compose up -d

BOARD_OWNER is applied once, to an empty database, and ignored from then on. It cannot be used to take ownership later: that is deliberate, because otherwise editing a file on disk would be a quieter route to the top than asking for it. Ownership moves only through Transferir titularidad inside the app.

Updating it later — a pull is not a deploy

The app's code is inside the image: docker/board/Dockerfile ends with COPY apps /srv/apps. So pulling new commits into ~/vienalatina changes nothing that is running, and neither does docker compose up -d --force-recreate — same image tag, same layers, same old code. Everything reports success and the server behaves exactly as it did before, which is the most expensive kind of nothing.

And git pull on its own will not fetch it. This is worth knowing before it costs you an afternoon: main on the server tracks gitea/main, while new work is pushed to a branch on GitHub. So a bare git pull asks Gitea, finds Gitea level with your local main, and answers "Already up to date." — which is true about the wrong remote, and reads exactly like there is nothing to do.

An update is a named pull, a push to Gitea so the build pipeline sees it, and a rebuild:

cd ~/vienalatina
git pull --no-rebase --no-edit github <the-branch-name>
git push gitea main
sudo bash scripts/deploy-board.sh

Both flags earn their place. main and the branch have genuinely diverged — main carries the previous merge, the branch carries the new work — and a git with no pull.rebase set refuses to guess, with "fatal: Need to specify how to reconcile divergent branches." --no-rebase says merge, which is what every deploy here has done. --no-edit then accepts the default merge message instead of opening an editor, which is a strange place to find yourself mid-deploy.

If github is not a remote yet, add it once — see the end of step 8:

git remote add github https://github.com/pablovolenski/vienalatina.git

That rebuilds the image, copies the compose file across, restarts, and prints the log. It never touches /srv/board/.env — that file holds the secrets and lives only on the server — but it does compare it against .env.example and name any setting that has appeared in the repository and is missing from yours. New settings are always added by hand.

The database schema is applied at start-up with CREATE TABLE IF NOT EXISTS, so a release that adds a table needs no migration step: the table appears when the new code does.

11.4 Route it through Caddy

Add to the vienalatina.com block in /etc/caddy/Caddyfile (already present in infra/caddy/Caddyfile):

@board path /comunidad /comunidad/*
reverse_proxy @board 127.0.0.1:8080

Then sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy.

Both paths are matched on purpose: Flask redirects /comunidad to /comunidad/, and matching only the trailing-slash form lets the bare path fall through to the static site and 404.

11.5 Back it up — this part is not optional

Everything else on this server is reproducible from the repository. The board's threads, comments and membership exist in exactly one place.

sudo apt install -y sqlite3
crontab -e
# 15 4 * * *  /home/pablo/vienalatina/scripts/backup-board.sh >> /home/pablo/board-backup.log 2>&1

The script uses SQLite's .backup rather than copying the file, because the database is live and in WAL mode — a plain cp can capture it missing its most recent commits. Test a restore before you rely on it: stop the container, gunzip a backup over /srv/board/data/board.db, start it again.

It also archives /srv/board/data/uploads, the pictures members attach to threads, as a second file uploads-<stamp>.tar.gz. This is not an extra: a database backup that completes looks exactly like a backup that worked, so before uploads were covered the nightly job would have gone on reporting success while silently leaving every photograph out. Restoring them is a plain tar -xzf, into /srv/board/data/.

If there are no uploads yet the log says so by name, rather than saying nothing — "nobody has posted a photo" and "the path moved a month ago and this has been archiving air" are otherwise the same empty line.

11.6 Who can do what

Owner Admin User
Post, comment, edit own ✓ ✓ ✓
Delete any post ✓ ✓ —
Edit someone else's post — — —
Pin and close threads ✓ ✓ —
Create users ✓ ✓ —
Create admins ✓ — —
Suspend a user ✓ ✓ —
Suspend an admin ✓ — —
Transfer ownership ✓ — —

Nobody edits anyone else's words, administrators included. Taking a post down is visible to the person who wrote it; rewriting it is not, and an admin who could do that could leave a sentence attributed to a member who never wrote it.

There is exactly one owner, and the database enforces it with a unique index rather than trusting the application to remember. The owner cannot be suspended or demoted by anyone, themselves included — to step down, transfer ownership to an admin.

11.7 Personal data

Members' names, emails and writing are personal data under GDPR.

  • Erasure: the owner's Eliminar removes the member row entirely and reassigns their threads and comments to a tombstone shown as "Miembro eliminado", so conversations other people took part in stay readable.
  • Access: any member can download everything they have written from Descargar mis datos.
  • Retention: soft-deleted posts stay in the database until removed by hand. If you want a real retention limit, that is a DELETE ... WHERE deleted_at < in this same cron slot — and a decision to take deliberately, not by default.

11.8 The content editor (/comunidad/contenido/)

Admins and the owner can write, edit and delete posts and pages from inside the members area, instead of Decap at /admin/.

Decap is still there and still works. Nothing was removed. Use the new editor for a few real posts first; if something turns out to be missing, switch tabs. Removing Decap is a separate decision — see below.

Nothing extra to install or configure: it runs in the container already serving /comunidad/, and commits through the Gitea OAuth application registered in §11.1. Two settings exist if the repository is ever renamed:

CONTENT_REPO=pablo/vienalatina      # owner/repo inside Gitea
CONTENT_BRANCH=main

How publishing works. The editor is a form that commits a file through Gitea's contents API. Gitea's webhook fires Woodpecker, and translate → build → deploy runs exactly as it does for a Decap commit — the pipeline cannot tell which editor wrote the file, which is what makes running both at once safe.

Commits are made with your own account, not a bot's, so git log shows who wrote each post and Gitea's permissions apply unchanged. Your access token is stored in the members-area database (never in a cookie) and refreshed automatically; Gitea expires them after about an hour, and without refreshing, saving would start failing mid-afternoon for no visible reason.

Filenames follow the same rules Decap used, because scripts/translate.py reads them: YYYY-MM-DD-slug.es.md for posts, slug.es.md for pages. A file whose name breaks that contract publishes in Spanish and is never translated, with nothing reported anywhere — which is why the tests import translate.py and run its parser over what the editor writes.

Two people editing one post is a visible conflict, not a silent overwrite: the form carries the file's git sha and Gitea rejects a write whose sha has moved on. You are asked to reopen the post rather than losing the other edit.

Images are committed as a second, separate commit before the post itself, so publishing with a picture produces two pipeline runs. Harmless, and the alternative — batching both into one commit via the git trees API — is considerably more code for something nobody sees.

What it deliberately does not do: rich-text editing (markdown with a preview button instead), a media library, drafts, or editing the generated German and Portuguese files. Those stay the pipeline's, and a hand-written translation is still frozen with manual_translation: true.

Worth tightening later

The OAuth application requests no explicit scope, so Gitea grants the default — full access to the account, which is more than the editor needs. Narrowing it to read:user write:repository is a one-line change in apps/board/gitea.py's authorize_url(), but it invalidates existing authorisations: everyone has to approve the app again. Worth doing while the member list is short, and worth testing on a throwaway account first, since a wrong scope string breaks sign-in for everybody.

Removing Decap, once you are confident

Not urgent — leaving it costs a folder and one wget in the pipeline:

  1. rm -rf static/admin/
  2. Delete the wget … decap-cms.js line from .woodpecker.yml
  3. Delete the decap-cms OAuth application in Gitea

11.9 Pictures on the board

Members can attach images when they start a thread or reply. Nothing to install: the files go to /data/uploads inside the container, which is /srv/board/data/uploads on the host — the same volume that already holds board.db, so there is one directory to back up rather than two.

They are deliberately not in the site repository. /comunidad/contenido/ uploads pictures by committing them, which is right for a post about to be published. A photo in a private thread is the opposite: committing it would send it through the build pipeline and out onto vienalatina.com. These are served by the app, behind the same login as the thread.

Three things the code does that are worth knowing if you ever change it:

  • The type is read from the first bytes, not the filename. A file called gato.png containing HTML is refused. Served back as image/png from our own domain, it would otherwise be a script running on vienalatina.com.
  • The stored name is generated. The name the browser sent is kept only as text to show a person, never as a path.
  • Deleting a post hides its pictures. Threads and comments are soft-deleted, so the serving route checks the parent is still live. Without that, taking a post down would leave its photo readable by anyone who noted the URL.

Limits: 4 images per message, and BOARD_UPLOAD_MAX_BYTES (8MB by default) each. Adding a picture to a post after publishing it means posting a reply — editing changes the words, and leaves the pictures alone.

11.10 Private messages (/comunidad/privados/)

An inbox between two members: conversations, unread badges, photos, blocking. Deliberately not live chat — that needs a connection held open per signed-in member, which gunicorn's sync workers cannot do, and it would be the first thing on this box with a real scaling limit.

Blocking is symmetric. One block stops messages in both directions, and either person can only remove their own. A block that silenced just the blocked person would leave the blocker able to keep writing, which is a megaphone rather than a safety feature.

Erasing a member deletes their private messages, both sides. A thread outlives its author as Miembro eliminado, because other people replied and the conversation would lose its shape; a two-party exchange has no such remainder. This does destroy the other person's copy — the uncomfortable half of the choice, and deliberate.

11.11 Schema changes: PRAGMA user_version

schema.sql is all CREATE TABLE IF NOT EXISTS, which handles exactly one kind of change — a brand-new table — and silently ignores every other. Until private messages, every change happened to be a new table, so nothing noticed.

apps/board/migrations.py holds numbered steps applied once each, in order, recorded in SQLite's own user_version. init_db runs the schema first and the migrations second: on an empty database the schema builds the current shape and each step finds its work done; on an existing one the schema adds what is new and the steps fix up what it could not touch.

Never edit a step that has shipped, and never renumber one. A server that has run it will not run it again, so a correction is a new step.

The first step rebuilds attachments so a picture can belong to a private message. It has to be a rebuild rather than an ALTER, because the table carries a CHECK constraint and SQLite has no DROP CONSTRAINT — adding the column works and the next insert is refused by a constraint that can no longer be removed. That is tested against a database built in the old shape, with rows in it, because a migration tested only on a fresh database is tested against the one case it was never needed for.

After deploying this, check that an existing photo still renders. That is the proof the rebuild kept real rows. Back up first — scripts/backup-board.sh — as with any migration.

12. Make Gitea look like the site

Members sign in to /comunidad/ through Gitea, so Gitea's sign-in form and its authorize dialog are part of the journey for everyone — not just for you, and not just for people who open a repository. Unthemed they are two dark screens in the middle of a cream-coloured site.

How often anyone sees them is worth knowing before judging the result: the authorize dialog appears once per person, ever — Gitea remembers the grant — and the sign-in form only when their Gitea session has lapsed, which "Remember This Device" pushes out to weeks. This is a first-impression fix.

The name, not just the colours

Themed or not, those two screens said Gitea — in the tab, the heading and the footer. A member has no idea what that is, and for anyone the platform is ever sold to it is a competitor's name on their login page. Three settings in /srv/gitea/docker-compose.yml take care of it:

GITEA__DEFAULT__APP_NAME=Viena Latina
GITEA__other__SHOW_FOOTER_POWERED_BY=false
GITEA__other__SHOW_FOOTER_VERSION=false

APP_NAME lives in app.ini's unnamed root section, which the environment mapping spells DEFAULT.

/srv/gitea/docker-compose.yml is a copy, and nothing kept it in step with this repository. That is worth stating plainly because it cost a week: every Gitea setting added here — CORS, the theme, OpenID, the register button, the footer — was committed and documented and never reached the server, because the only thing that syncs a compose file is deploy-board.sh, and it syncs the board's. The file on the server stays valid, the container stays healthy, and the setting is simply absent.

cd ~/vienalatina && sudo bash scripts/deploy-gitea.sh

That copies infra/gitea/docker-compose.yml across (keeping the old one as .bak and printing the diff, since it may have been hand-edited), restarts, and then reads the settings back out of the running container and prints them. Treat that output as the only evidence: a line missing there is a setting not in effect, whatever the compose file says.

That should show APP_NAME = Viena Latina. Hiding the version is the one with a security argument as well as a cosmetic one: it tells a passer-by exactly which advisories to try.

On the licence, since this is rebranding somebody else's software: Gitea is MIT, whose only obligation is that the copyright and permission notice travel with copies of the software. We are not redistributing it — the official image runs unmodified, with its own LICENSE file untouched, and we talk to it over HTTP. MIT requires no attribution in a user interface, and Gitea itself ships SHOW_FOOTER_POWERED_BY as a supported setting, which settles what the project intends. The name is a trademark of Gitea Limited; that restricts using it to brand something else, not declining to display it. Redistributing a modified Gitea under its own name would be a different question — this is not that.

The theme

Unlike Decap, Gitea supports this properly: a theme is a CSS file in a directory it already reads.

cd ~/vienalatina
git pull --no-rebase --no-edit gitea main
bash scripts/gitea-theme.sh

GITEA__ui__DEFAULT_THEME=vienalatina is already in infra/gitea/docker-compose.yml, so it arrives with the sync — no hand-editing of the server's copy:

cd ~/vienalatina && sudo bash scripts/deploy-gitea.sh

The script prints DEFAULT_THEME back out of the container. If it says vienalatina and the screens are still grey, the setting is fine and the theme file was never built — run bash scripts/gitea-theme.sh first. Those are two different failures with one symptom, which is why the script names both.

Check it in a private window at https://git.vienalatina.com/user/login — cream background, the Viena Latina wordmark, #c0391c buttons. Then browse a repository and open a commit: a theme that only looks right on the login page is half done.

Re-run it after every Gitea upgrade

The theme is Gitea's own light theme with our colours appended, and the base is read out of the running container so it matches the installed version. A new Gitea release can introduce variables our overrides do not mention, and a base frozen in the repository would drift out of date in ways nobody notices until a page looks wrong.

bash scripts/gitea-theme.sh
cd /srv/gitea && sudo docker compose restart gitea

Nothing breaks if you forget — an unknown variable is a declaration nobody reads, so the worst case is a corner that stays grey.

Signing out

One click. Salir clears the session and returns to the sign-in form, which asks for a password.

This section used to explain at length why that was not true — the session belonged to the git server, its logout is POST-only and unreachable from another domain, and one click on Entrar signed you straight back in. All of that followed from delegating identity, and none of it survived taking it back.

11.12 When nobody can sign in

Every path to a first password goes through email: the invitation when a member is added, and ¿olvidaste tu contraseña? afterwards. If the mailbox is down and the owner is locked out, that is a circle with no way in.

sudo bash scripts/set-password.sh pablo

Prompts for a password without echoing it, hashes it with the same code the application uses, inside the running container. Never takes the password as an argument — an argument is visible in ps to everyone on the box.

Test it while you still have another way in, not on the day you need it.

13. Email: invitations and passwords

Until this is configured, an admin can add members but nobody else can get in. The password was shown once to the admin, and Gitea's Forgot password answers "Account recovery is disabled because no email is set up". That was the state the members area shipped in; this section is what fixes it.

13.1 What happens now

An admin enters a username and an email. The server creates the Gitea account with a random password nobody ever sees, the admin included, and emails the member a link. The link opens a Viena Latina page where they choose their own password, and only then can the account be used.

The same mechanism powers ¿Olvidaste tu contraseña? on the sign-in page, which replaces Gitea's dead recovery page. Nobody leaves the site for either.

13.2 SMTP settings

These are the details of the hola@vienalatina.com mailbox. If that mailbox is part of the old Hetzner shared hosting, they are in the Konsole panel under the email account.

sudo nano /srv/board/.env
MAIL_HOST=
MAIL_PORT=587
MAIL_SECURITY=starttls
MAIL_USER=hola@vienalatina.com
MAIL_PASSWORD=
MAIL_FROM=Viena Latina <hola@vienalatina.com>

Port and security go together. 465 means MAIL_SECURITY=ssl; 587 means starttls. Mismatching the pair is the usual reason a mailbox that works perfectly in a mail client fails here, and the error it produces is a timeout rather than anything that names the cause.

cd ~/vienalatina && sudo bash scripts/deploy-board.sh

Not docker compose up -d --force-recreate on its own: if the code that reads these settings arrived in the same pull, that restart runs the old image and mail stays unconfigured with the settings sitting right there in .env. The script rebuilds first.

The variables also have to be listed in /srv/board/docker-compose.yml, which they now are. Compose does not hand .env to a container — it substitutes into the compose file — so a setting added to .env and not to the compose file is read by nobody. apps/board/tests/test_deployment.py fails the build if the two ever drift apart again.

Test it by adding a member with an address you can read. If the mail cannot be sent, the screen says so and shows you the invitation link to pass on by hand — the account is created either way, so a mail problem delays somebody rather than stranding them.

A link is enough to set the password on that account, so it is treated as a credential:

  • single use — following it and choosing a password spends it
  • invitations last 7 days, resets 1 hour
  • only a hash is stored, so a leaked database backup is a list of useless hashes rather than a set of live keys
  • asking for a new link invalidates the previous one, so an older email sitting in an inbox stops working
  • recovery answers identically for an address that belongs to a member and one that does not, and stops after three attempts in fifteen minutes

If a member says a link does not work, the fix is always to send another. There is deliberately no way to find out why one failed from the page itself: that distinction would tell whoever holds a stale link something about the account behind it.

13.4 The sign-in page loses two tabs

GITEA__openid__ENABLE_OPENID_SIGNIN=false and GITEA__service__SHOW_REGISTRATION_BUTTON=false, already in infra/gitea/docker-compose.yml. OpenID is sign-in with an external identity URL, which nobody here will use, and the register button contradicts DISABLE_REGISTRATION — it invited people to try something the server then refused.

It also loses a third thing, the Forgot password? link, which goes to a page that answers "Account recovery is disabled because no email is set up" and always will: the SMTP details are the members area's, and this container has no mailer and needs none. Recovery lives at vienalatina.com/comunidad/recuperar and works. The link is hidden by a rule in the theme file rather than by replacing the template, so a Gitea upgrade cannot quietly undo it — and if the selector ever stops matching, the link reappears rather than the page breaking.

cd ~/vienalatina && sudo bash scripts/deploy-gitea.sh