Add infra files and step-by-step server setup guide
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- infra/caddy/Caddyfile: git.* and ci.* proxies live now; main-site block (with WP 301 redirects) commented until cutover day - infra/gitea and infra/woodpecker: Docker Compose bound to localhost behind Caddy, with .env.example for the Woodpecker OAuth credentials - docs/server-setup.md: every command from ordering the CX22 through the end-to-end translation test, plus the cutover-day checklist Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017z2rT1oN7vMS4ggo23n5WG
This commit is contained in:
parent
72894b8907
commit
66903016c7
@ -55,6 +55,11 @@ categories: [Gastronomía]
|
|||||||
Any DeepL error fails the pipeline visibly (red X, one-click retry) —
|
Any DeepL error fails the pipeline visibly (red X, one-click retry) —
|
||||||
no half-translated sets ever ship.
|
no half-translated sets ever ship.
|
||||||
|
|
||||||
|
## Server setup
|
||||||
|
|
||||||
|
Full command-by-command walkthrough: **[docs/server-setup.md](docs/server-setup.md)**.
|
||||||
|
The Caddyfile and Docker Compose files it uses live in [infra/](infra/).
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
270
docs/server-setup.md
Normal file
270
docs/server-setup.md
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
# 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](https://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):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
local$ dig +short git.vienalatina.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. First login + basic hardening
|
||||||
|
|
||||||
|
```sh
|
||||||
|
local$ ssh root@<SERVER-IP>
|
||||||
|
```
|
||||||
|
|
||||||
|
Update and create your user:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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/`
|
||||||
|
- Save the **Client ID** — it goes into `static/admin/config.yml` (step 9).
|
||||||
|
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
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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 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):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd ~/vienalatina
|
||||||
|
git remote add gitea https://git.vienalatina.com/pablo/vienalatina.git
|
||||||
|
git push gitea main
|
||||||
|
```
|
||||||
|
|
||||||
|
(It 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:
|
||||||
|
- `deepl_api_key` — your DeepL key (the same one from the WP plugin
|
||||||
|
settings page).
|
||||||
|
- `gitea_push_token` — the bot token from step 6.3.
|
||||||
|
|
||||||
|
The push in the step above has already triggered a first pipeline — it likely
|
||||||
|
ran before the secrets existed, so open it and press the retry button. All
|
||||||
|
three steps (translate → build → deploy) should go green, and
|
||||||
|
`/var/www/vienalatina.com/` on the server now contains the built site:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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.)
|
||||||
|
|
||||||
|
## 10. Test the translation loop end-to-end
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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"), spot-check the built site by IP or with
|
||||||
|
`curl -H "Host: vienalatina.com" http://127.0.0.1/...` on the server.
|
||||||
|
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, red-pipeline DeepL failure test,
|
||||||
|
`manual_translation: true` freeze test).
|
||||||
|
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)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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
|
||||||
|
```
|
||||||
63
infra/caddy/Caddyfile
Normal file
63
infra/caddy/Caddyfile
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# /etc/caddy/Caddyfile — copy this file there, then: sudo systemctl reload caddy
|
||||||
|
#
|
||||||
|
# Caddy fetches and renews Let's Encrypt certificates automatically the first
|
||||||
|
# time a domain's DNS points at this server. git.* and ci.* work immediately;
|
||||||
|
# the vienalatina.com block stays commented until cutover day (Sunday evening),
|
||||||
|
# because Caddy can only get its certificate once the A record points here.
|
||||||
|
|
||||||
|
git.vienalatina.com {
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
ci.vienalatina.com {
|
||||||
|
reverse_proxy 127.0.0.1:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UNCOMMENT EVERYTHING BELOW ON CUTOVER DAY (after flipping the A record)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# www.vienalatina.com {
|
||||||
|
# redir https://vienalatina.com{uri} 301
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# vienalatina.com {
|
||||||
|
# root * /var/www/vienalatina.com
|
||||||
|
# encode zstd gzip
|
||||||
|
# file_server
|
||||||
|
#
|
||||||
|
# # llms.txt is markdown (matches the old WP behaviour)
|
||||||
|
# header /llms.txt Content-Type "text/markdown; charset=utf-8"
|
||||||
|
#
|
||||||
|
# # --- 301s for old WordPress URL patterns -----------------------------
|
||||||
|
#
|
||||||
|
# # /?p=123 style permalinks → home (add specific mappings as GSC reports them)
|
||||||
|
# @wp_query_permalink {
|
||||||
|
# path /
|
||||||
|
# query p=*
|
||||||
|
# }
|
||||||
|
# redir @wp_query_permalink / 301
|
||||||
|
#
|
||||||
|
# # WP category base was /categoria/… (Spanish slugs); Hugo uses /categories/…
|
||||||
|
# @old_category path_regexp oldcat ^/categoria/(.*)$
|
||||||
|
# redir @old_category /categories/{re.oldcat.1} 301
|
||||||
|
#
|
||||||
|
# # WP author archives have no Hugo equivalent → home
|
||||||
|
# @old_author path /autor/*
|
||||||
|
# redir @old_author / 301
|
||||||
|
#
|
||||||
|
# # Old WP media library URLs → migrated uploads folder
|
||||||
|
# @old_uploads path_regexp oldup ^/wp-content/uploads/(?:\d{4}/\d{2}/)?(.*)$
|
||||||
|
# redir @old_uploads /uploads/{re.oldup.1} 301
|
||||||
|
#
|
||||||
|
# # Anything else that starts with /wp- doesn't exist anymore
|
||||||
|
# @wp_leftovers path /wp-admin/* /wp-login.php /wp-json/* /xmlrpc.php
|
||||||
|
# redir @wp_leftovers / 301
|
||||||
|
#
|
||||||
|
# # Custom 404 falls back to Hugo's 404 page
|
||||||
|
# handle_errors {
|
||||||
|
# @404 expression {err.status_code} == 404
|
||||||
|
# rewrite @404 /404.html
|
||||||
|
# file_server
|
||||||
|
# }
|
||||||
|
# }
|
||||||
22
infra/gitea/docker-compose.yml
Normal file
22
infra/gitea/docker-compose.yml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Gitea — self-hosted git + OAuth provider for Decap and Woodpecker.
|
||||||
|
# Copy this directory to /srv/gitea/ on the server, then: docker compose up -d
|
||||||
|
# Web UI is bound to localhost only; Caddy proxies git.vienalatina.com to it.
|
||||||
|
|
||||||
|
services:
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:1.22
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- USER_UID=1000
|
||||||
|
- USER_GID=1000
|
||||||
|
- GITEA__server__DOMAIN=git.vienalatina.com
|
||||||
|
- GITEA__server__ROOT_URL=https://git.vienalatina.com/
|
||||||
|
- GITEA__server__SSH_DOMAIN=git.vienalatina.com
|
||||||
|
- GITEA__service__DISABLE_REGISTRATION=true
|
||||||
|
- GITEA__webhook__ALLOWED_HOST_LIST=ci.vienalatina.com
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000"
|
||||||
9
infra/woodpecker/.env.example
Normal file
9
infra/woodpecker/.env.example
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Copy to .env and fill in. Never commit the real .env.
|
||||||
|
|
||||||
|
# From Gitea: Site Administration → Applications → OAuth2 application
|
||||||
|
# (redirect URI: https://ci.vienalatina.com/authorize)
|
||||||
|
WOODPECKER_GITEA_CLIENT=
|
||||||
|
WOODPECKER_GITEA_SECRET=
|
||||||
|
|
||||||
|
# Shared secret between server and agent — generate with: openssl rand -hex 32
|
||||||
|
WOODPECKER_AGENT_SECRET=
|
||||||
35
infra/woodpecker/docker-compose.yml
Normal file
35
infra/woodpecker/docker-compose.yml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Woodpecker CI — server + one agent on the same VPS.
|
||||||
|
# Copy this directory to /srv/woodpecker/, create .env from .env.example,
|
||||||
|
# then: docker compose up -d
|
||||||
|
# Web UI is bound to localhost only; Caddy proxies ci.vienalatina.com to it.
|
||||||
|
|
||||||
|
services:
|
||||||
|
server:
|
||||||
|
image: woodpeckerci/woodpecker-server:v2
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8000:8000"
|
||||||
|
environment:
|
||||||
|
- WOODPECKER_HOST=https://ci.vienalatina.com
|
||||||
|
- WOODPECKER_OPEN=false
|
||||||
|
- WOODPECKER_ADMIN=pablo # your Gitea username
|
||||||
|
- WOODPECKER_GITEA=true
|
||||||
|
- WOODPECKER_GITEA_URL=https://git.vienalatina.com
|
||||||
|
- WOODPECKER_GITEA_CLIENT=${WOODPECKER_GITEA_CLIENT}
|
||||||
|
- WOODPECKER_GITEA_SECRET=${WOODPECKER_GITEA_SECRET}
|
||||||
|
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
|
||||||
|
volumes:
|
||||||
|
- ./data:/var/lib/woodpecker
|
||||||
|
|
||||||
|
agent:
|
||||||
|
image: woodpeckerci/woodpecker-agent:v2
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
environment:
|
||||||
|
- WOODPECKER_SERVER=server:9000
|
||||||
|
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
|
||||||
|
- WOODPECKER_BACKEND=docker
|
||||||
|
- WOODPECKER_MAX_WORKFLOWS=2
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
Loading…
Reference in New Issue
Block a user