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) —
|
||||
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
|
||||
|
||||
```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