diff --git a/Dockerfile b/Dockerfile
index 725e0fe..6db22ac 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -27,6 +27,10 @@ ARG NEXT_PUBLIC_SITE_ID
ENV NEXT_PUBLIC_SERVER_URL=$NEXT_PUBLIC_SERVER_URL
ENV NEXT_PUBLIC_SITE_ID=$NEXT_PUBLIC_SITE_ID
+# Cap the Node heap during `next build` so builds fit on small servers
+ARG NODE_OPTIONS=--max-old-space-size=4096
+ENV NODE_OPTIONS=$NODE_OPTIONS
+
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
diff --git a/countdown.html b/countdown.html
deleted file mode 100644
index 16c77f5..0000000
--- a/countdown.html
+++ /dev/null
@@ -1,354 +0,0 @@
-
-
-
-
-
- Countdown – Heilige Mutter Teresa Chemnitz
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Bald gehen wir online
-
-
-
-
-- Tage
-
-- Stunden
-
-- Minuten
-
-- Sekunden
-
-
-
-
-
diff --git a/infra/README.md b/infra/README.md
index 3046561..dd487d5 100644
--- a/infra/README.md
+++ b/infra/README.md
@@ -255,67 +255,26 @@ The Forgejo Actions workflow (`.forgejo/workflows/deploy.yml`) triggers on push
---
-## Countdown Static Site (Caddy)
+## Production Setup
-The static `countdown.html` (repo root) is served by the **existing test VPS**
-Caddy alongside the app reverse-proxies — no separate box. The domains live in
-`countdown_domains` in `inventory/test.yml`:
-
-```yaml
-countdown_domains:
- - hl-mutter-teresa-chemnitz.de
- - www.hl-mutter-teresa-chemnitz.de
-```
-
-When this var is set, the `caddy` role uploads `countdown.html` to
-`/var/www/countdown/index.html` and renders a Caddyfile block that serves it for
-all listed names (`chemnitz_live` defaults to `false` = countdown). Caddy obtains
-and auto-renews TLS automatically — point each domain's DNS A/AAAA record at the
-VPS first so the ACME challenge resolves. `setup.yml` provisions it as part of the
-normal server setup.
-
-### Go live: countdown → staging app
-
-`switch-to-staging.yml` re-renders the Caddyfile so the chemnitz domains
-`reverse_proxy` to the staging app (port 3001) instead of serving the static
-page, then validates and reloads Caddy:
+Production runs on its own VPS (4 vCPU / 8 GB) serving `hl-mutter-teresa-chemnitz.de`,
+**without Forgejo** (`install_forgejo: false` in the inventory) — code is pulled
+from git.skick.app via a read-only deploy key. The inventory is
+`inventory/production.yml`; only the VPS IP needs filling in, everything else
+comes from the vault.
```bash
cd infra/ansible
-# Switch the domains to the live staging app:
-ansible-playbook playbooks/switch-to-staging.yml -i inventory/test.yml --ask-vault-pass
+# One-time provisioning (common, postgres, caddy, app):
+ansible-playbook playbooks/setup.yml -i inventory/production.yml --vault-password-file ~/.config/chemnitz-vault-pass
-# Revert to the countdown page:
-ansible-playbook playbooks/switch-to-staging.yml -i inventory/test.yml --ask-vault-pass -e chemnitz_live=false
+# Deploys (pull staging branch, build, migrate, restart):
+ansible-playbook playbooks/deploy-production.yml -i inventory/production.yml --vault-password-file ~/.config/chemnitz-vault-pass
```
-The playbook is idempotent, so you can run it ahead of time to dry-run the
-cutover and flip it back. It reuses the TLS cert Caddy already holds from
-countdown mode (same domains), so there's no re-issue at switch time.
-
-> After go-live, set `chemnitz_live: true` in `inventory/test.yml` so a later full
-> `setup.yml` run keeps the domains pointed at the app instead of reverting to the
-> countdown page.
->
-> Note: the staging image is built with `NEXT_PUBLIC_SERVER_URL` set to its
-> `skick.app` domain, so canonical URLs/sitemap still point there. If
-> `hl-mutter-teresa-chemnitz.de` should be canonical, rebuild staging with that URL.
-
----
-
-## Production Setup
-
-1. Copy and edit the production inventory:
- ```bash
- cp infra/ansible/inventory/production.yml infra/ansible/inventory/my-production.yml
- ```
-2. Fill in the production VPS IP, domain, and secrets
-3. Run the playbook (skip Forgejo):
- ```bash
- ansible-playbook playbooks/setup.yml -i inventory/my-production.yml --ask-vault-pass
- ```
-4. Set up a deploy workflow for production (triggered on tags/releases)
+The full go-live procedure (provisioning, data migration from staging, DNS
+cutover) is documented in [`infra/runbooks/production-migration.md`](runbooks/production-migration.md).
---
diff --git a/infra/ansible/inventory/production.yml b/infra/ansible/inventory/production.yml
index 5c03d78..abad405 100644
--- a/infra/ansible/inventory/production.yml
+++ b/infra/ansible/inventory/production.yml
@@ -1,32 +1,55 @@
-# Production inventory — fill in when ready
all:
hosts:
production-vps:
- ansible_host: YOUR_PRODUCTION_VPS_IP
+ ansible_host: 217.154.211.139
ansible_user: root
ansible_ssh_private_key_file: ~/.ssh/id_ed25519
vars:
- swap_size_mb: 2048
+ # No Forgejo on production — code is pulled from git.skick.app
+ install_forgejo: false
+
+ # 4 GB swap file: absorbs docker-build peaks on the 8 GB host
+ swap_size_mb: 4096
+
+ # Docker
docker_network: church-website-net
+ # PostgreSQL
postgres_container_name: postgres
postgres_image: postgis/postgis:16-3.4
postgres_volume: pgdata
+ # Tuned for 4 vCPU / 8 GB shared with the Next.js app and on-server builds
+ postgres_server_args: >-
+ -c max_connections=40
+ -c shared_buffers=512MB
+ -c effective_cache_size=2GB
+ -c work_mem=16MB
+ -c maintenance_work_mem=128MB
+ -c checkpoint_completion_target=0.9
+ -c max_wal_size=1GB
+ -c random_page_cost=1.1
+ -c effective_io_concurrency=200
+ # Databases
databases:
- name: church_website
user: church_website
password: "{{ vault_db_password }}"
+ # Caddy
caddy_domains:
- - domain: YOUR_PRODUCTION_DOMAIN
+ - domain: hl-mutter-teresa-chemnitz.de
proxy_port: 3001
+ caddy_redirects:
+ - from: www.hl-mutter-teresa-chemnitz.de
+ to: hl-mutter-teresa-chemnitz.de
+ # App environments
app_environments:
- name: production
port: 3001
- domain: YOUR_PRODUCTION_DOMAIN
+ domain: hl-mutter-teresa-chemnitz.de
db_name: church_website
db_user: church_website
db_password: "{{ vault_db_password }}"
@@ -35,8 +58,9 @@ all:
google_bucket: "{{ vault_google_bucket }}"
resend_api_key: "{{ vault_resend_api_key }}"
+ # Repo
repo_dir: /opt/church-website/repo
envs_dir: /opt/church-website/envs
scripts_dir: /opt/church-website/scripts
repo_url: "{{ vault_repo_url }}"
- repo_branch: master
+ repo_branch: staging
diff --git a/infra/ansible/inventory/test.yml b/infra/ansible/inventory/test.yml
index 929217e..42877c8 100644
--- a/infra/ansible/inventory/test.yml
+++ b/infra/ansible/inventory/test.yml
@@ -33,16 +33,6 @@ all:
- domain: git.skick.app
proxy_port: 3003
- # Chemnitz domains: served by Caddy as the static countdown page until go-live.
- # The setup/caddy role renders them in countdown mode (chemnitz_live defaults
- # to false). Run playbooks/switch-to-staging.yml to flip them to the staging
- # app (port 3001) and reload Caddy. Point DNS at this VPS first so Caddy can
- # obtain TLS for each name.
- countdown_domains:
- - hl-mutter-teresa-chemnitz.de
- - www.hl-mutter-teresa-chemnitz.de
- countdown_live_port: 3001 # staging app port the domains proxy to once live
-
# Forgejo
forgejo_domain: git.skick.app
forgejo_container_name: forgejo
diff --git a/infra/ansible/playbooks/deploy-production.yml b/infra/ansible/playbooks/deploy-production.yml
new file mode 100644
index 0000000..43379d9
--- /dev/null
+++ b/infra/ansible/playbooks/deploy-production.yml
@@ -0,0 +1,20 @@
+---
+- name: Deploy production environment
+ hosts: all
+ become: true
+
+ tasks:
+ - name: Pull {{ repo_branch }} branch
+ ansible.builtin.git:
+ repo: "{{ repo_url }}"
+ dest: "{{ repo_dir }}"
+ version: "{{ repo_branch }}"
+ force: true
+ accept_hostkey: true
+
+ - name: Build and deploy production
+ ansible.builtin.include_role:
+ name: app
+ tasks_from: deploy_env
+ vars:
+ env: "{{ app_environments | selectattr('name', 'equalto', 'production') | first }}"
diff --git a/infra/ansible/playbooks/setup.yml b/infra/ansible/playbooks/setup.yml
index a4d0a07..3a7a167 100644
--- a/infra/ansible/playbooks/setup.yml
+++ b/infra/ansible/playbooks/setup.yml
@@ -7,5 +7,5 @@
- common
- postgresql
- caddy
- - forgejo
+ - { role: forgejo, when: install_forgejo | default(true) }
- app
diff --git a/infra/ansible/playbooks/switch-to-staging.yml b/infra/ansible/playbooks/switch-to-staging.yml
deleted file mode 100644
index 4ccb77d..0000000
--- a/infra/ansible/playbooks/switch-to-staging.yml
+++ /dev/null
@@ -1,47 +0,0 @@
----
-# Flip the chemnitz domains (countdown_domains) from the static countdown page
-# to the live staging app, then validate + reload Caddy.
-#
-# ansible-playbook playbooks/switch-to-staging.yml -i inventory/test.yml --ask-vault-pass
-#
-# Re-runnable and idempotent — run it to go live, or to dry-run the cutover.
-# To revert to the countdown page:
-# ansible-playbook playbooks/switch-to-staging.yml -i inventory/test.yml --ask-vault-pass -e chemnitz_live=false
-- name: Switch chemnitz domains between countdown and the staging app
- hosts: all
- become: true
-
- vars:
- chemnitz_live: true
-
- tasks:
- - name: Fail early if the chemnitz domains are not configured
- ansible.builtin.assert:
- that:
- - countdown_domains is defined
- - countdown_domains | length > 0
- fail_msg: "countdown_domains must be set in the inventory before switching."
-
- - name: Render Caddyfile ({{ 'staging app' if chemnitz_live | bool else 'countdown page' }})
- ansible.builtin.template:
- src: "{{ playbook_dir }}/../roles/caddy/templates/Caddyfile.j2"
- dest: /etc/caddy/Caddyfile
- mode: "0644"
- register: caddyfile_result
-
- - name: Validate Caddyfile
- ansible.builtin.command: caddy validate --adapter caddyfile --config /etc/caddy/Caddyfile
- changed_when: false
-
- - name: Reload Caddy
- ansible.builtin.systemd:
- name: caddy
- state: reloaded
- when: caddyfile_result is changed
-
- - name: Report active mode
- ansible.builtin.debug:
- msg: >-
- chemnitz domains now serving
- {{ 'the staging app (port ' ~ (countdown_live_port | default(3001)) ~ ')'
- if chemnitz_live | bool else 'the countdown page' }}.
diff --git a/infra/ansible/roles/caddy/tasks/main.yml b/infra/ansible/roles/caddy/tasks/main.yml
index 08223c7..0c6972c 100644
--- a/infra/ansible/roles/caddy/tasks/main.yml
+++ b/infra/ansible/roles/caddy/tasks/main.yml
@@ -24,24 +24,6 @@
state: present
update_cache: true
-- name: Create countdown web root
- ansible.builtin.file:
- path: /var/www/countdown
- state: directory
- owner: caddy
- group: caddy
- mode: "0755"
- when: countdown_domains is defined and countdown_domains | length > 0
-
-- name: Upload countdown page
- ansible.builtin.copy:
- src: "{{ playbook_dir }}/../../../countdown.html"
- dest: /var/www/countdown/index.html
- owner: caddy
- group: caddy
- mode: "0644"
- when: countdown_domains is defined and countdown_domains | length > 0
-
- name: Deploy Caddyfile
ansible.builtin.template:
src: Caddyfile.j2
diff --git a/infra/ansible/roles/caddy/templates/Caddyfile.j2 b/infra/ansible/roles/caddy/templates/Caddyfile.j2
index a7f2379..87c7718 100644
--- a/infra/ansible/roles/caddy/templates/Caddyfile.j2
+++ b/infra/ansible/roles/caddy/templates/Caddyfile.j2
@@ -4,14 +4,9 @@
}
{% endfor %}
-{% if countdown_domains is defined and countdown_domains | length > 0 %}
-{{ countdown_domains | join(', ') }} {
-{% if chemnitz_live | default(false) | bool %}
- reverse_proxy localhost:{{ countdown_live_port | default(3001) }}
-{% else %}
- root * /var/www/countdown
- file_server
-{% endif %}
+{% for redirect in caddy_redirects | default([]) %}
+{{ redirect.from }} {
+ redir https://{{ redirect.to }}{uri} permanent
}
-{% endif %}
+{% endfor %}
diff --git a/infra/ansible/roles/common/tasks/main.yml b/infra/ansible/roles/common/tasks/main.yml
index bcf9509..46f449d 100644
--- a/infra/ansible/roles/common/tasks/main.yml
+++ b/infra/ansible/roles/common/tasks/main.yml
@@ -17,13 +17,45 @@
- git
state: present
+# Swap (only when the inventory defines swap_size_mb)
+- name: Create swap file
+ ansible.builtin.shell:
+ cmd: fallocate -l {{ swap_size_mb }}M /swapfile && chmod 600 /swapfile && mkswap /swapfile
+ creates: /swapfile
+ when: swap_size_mb is defined
+
+- name: Enable swap file
+ ansible.builtin.shell: swapon --show=NAME --noheadings | grep -q /swapfile || swapon /swapfile
+ changed_when: false
+ when: swap_size_mb is defined
+
+- name: Persist swap in fstab
+ ansible.builtin.lineinfile:
+ path: /etc/fstab
+ line: /swapfile none swap sw 0 0
+ when: swap_size_mb is defined
+
+- name: Set swappiness
+ ansible.builtin.copy:
+ content: "vm.swappiness=10\n"
+ dest: /etc/sysctl.d/99-swappiness.conf
+ mode: "0644"
+ register: swappiness_conf
+ when: swap_size_mb is defined
+
+- name: Apply swappiness
+ ansible.builtin.command: sysctl -w vm.swappiness=10
+ when: swap_size_mb is defined and swappiness_conf.changed
+
# Firewall
- name: Configure UFW rules
ansible.builtin.shell: |
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
+ {% if install_forgejo | default(true) %}
ufw allow {{ forgejo_ssh_port | default(2222) }}/tcp
+ {% endif %}
ufw --force enable
ufw default deny incoming
changed_when: false
diff --git a/infra/ansible/roles/postgresql/tasks/main.yml b/infra/ansible/roles/postgresql/tasks/main.yml
index be23b5f..49b7f14 100644
--- a/infra/ansible/roles/postgresql/tasks/main.yml
+++ b/infra/ansible/roles/postgresql/tasks/main.yml
@@ -27,7 +27,7 @@
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD={{ vault_postgres_root_password }} \
-p 127.0.0.1:5432:5432 \
- {{ postgres_image }}
+ {{ postgres_image }} {{ postgres_server_args | default('') }}
when: postgres_status.stdout == ""
- name: Wait for PostgreSQL to be ready
diff --git a/infra/runbooks/production-migration.md b/infra/runbooks/production-migration.md
new file mode 100644
index 0000000..8148837
--- /dev/null
+++ b/infra/runbooks/production-migration.md
@@ -0,0 +1,180 @@
+# Runbook: Migrate to the production server (hl-mutter-teresa-chemnitz.de)
+
+One-off procedure to provision the new production VPS (4 vCPU / 8 GB, no Forgejo),
+copy the staging data (DB + uploads) onto it, and cut DNS over from the old
+server (178.104.35.59).
+
+Shell variables used throughout (run on your dev machine):
+
+```bash
+OLD=root@178.104.35.59
+NEW=root@217.154.211.139
+```
+
+---
+
+## Phase 0 — Prep (days before)
+
+1. Lower the DNS TTL for `hl-mutter-teresa-chemnitz.de` and `www.hl-mutter-teresa-chemnitz.de` to 300s.
+2. Set the production secrets in the vault:
+ ```bash
+ cd infra/ansible
+ ansible-vault edit inventory/group_vars/all/vault.yml --vault-password-file ~/.config/chemnitz-vault-pass
+ ```
+ - `vault_db_password`: new random value (e.g. `openssl rand -hex 24`)
+ - `vault_payload_secret`: **copy the value of `vault_payload_secret_staging`**.
+ The migrated database may contain fields encrypted with the staging secret
+ (Payload API-key fields); a different secret would silently break them.
+3. Fill the production VPS IP into `inventory/production.yml` (`ansible_host`).
+4. Make sure your SSH public key is on the new VPS root account (`ssh $NEW true` works without password).
+
+## Phase 1 — Provision + initial deploy
+
+```bash
+cd infra/ansible
+ansible-playbook playbooks/setup.yml -i inventory/production.yml --vault-password-file ~/.config/chemnitz-vault-pass
+```
+
+- The **first run fails at "Clone or update repository"**: the server's freshly
+ generated SSH key isn't known to Forgejo yet. Copy the public key printed by
+ the "Show SSH public key" task and add it in Forgejo
+ (git.skick.app → church-website repo → Settings → Deploy Keys, **read-only**).
+- Re-run the same command; it now completes clone → build → migrate → start `app-production`.
+
+Sanity checks:
+
+```bash
+ssh $NEW "curl -sI http://127.0.0.1:3001 | head -1" # HTTP 200/30x
+ssh $NEW "docker exec postgres psql -U postgres -c 'SHOW shared_buffers;'" # 512MB
+ssh $NEW "swapon --show" # 4G /swapfile
+```
+
+Caddy will log ACME errors for the domain until DNS moves — expected, ignore.
+
+## Phase 2 — Data sync (repeatable; rehearse once before cutover)
+
+### 2a. Database
+
+```bash
+# Dump staging on the old server
+ssh $OLD "docker exec postgres pg_dump -U postgres --no-owner --no-acl church_website_staging | gzip" > staging.sql.gz
+
+# Recreate the production DB empty
+ssh $NEW "docker stop app-production"
+ssh $NEW "docker exec postgres psql -U postgres -c \"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'church_website' AND pid <> pg_backend_pid();\""
+ssh $NEW "docker exec postgres psql -U postgres -c 'DROP DATABASE IF EXISTS church_website;'"
+ssh $NEW "docker exec postgres psql -U postgres -c 'CREATE DATABASE church_website OWNER church_website;'"
+ssh $NEW "docker exec postgres psql -U postgres -d church_website -c 'CREATE EXTENSION IF NOT EXISTS postgis;'"
+
+# Restore (spatial_ref_sys errors are harmless — that table is owned by postgis)
+gunzip -c staging.sql.gz | ssh $NEW "docker exec -i postgres psql -U postgres -d church_website"
+```
+
+The drop/recreate is required even though Phase 1 already created a schema:
+the dump's `payload_migrations` table must be authoritative, not merged into a
+freshly migrated schema.
+
+Reassign ownership to the app user (same block as `copy-staging-to-test.yml`):
+
+```bash
+ssh $NEW "docker exec -i postgres psql -U postgres -d church_website" <<'SQL'
+DO $$
+DECLARE
+ r RECORD;
+BEGIN
+ FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
+ EXECUTE 'ALTER TABLE public.' || quote_ident(r.tablename) || ' OWNER TO church_website';
+ END LOOP;
+ FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
+ EXECUTE 'ALTER SEQUENCE public.' || quote_ident(r.sequencename) || ' OWNER TO church_website';
+ END LOOP;
+ FOR r IN SELECT typname FROM pg_type t
+ WHERE t.typnamespace = 'public'::regnamespace
+ AND t.typtype = 'e'
+ AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = t.oid AND d.deptype = 'e')
+ LOOP
+ EXECUTE 'ALTER TYPE public.' || quote_ident(r.typname) || ' OWNER TO church_website';
+ END LOOP;
+END
+$$;
+SQL
+```
+
+### 2b. Upload volumes
+
+```bash
+ssh $OLD "docker run --rm -v uploads-staging-media:/src:ro alpine tar -C /src -czf - ." \
+ | ssh $NEW "docker run --rm -i -v uploads-production-media:/dst alpine sh -c 'rm -rf /dst/* && tar -xzf - -C /dst'"
+
+ssh $OLD "docker run --rm -v uploads-staging-documents:/src:ro alpine tar -C /src -czf - ." \
+ | ssh $NEW "docker run --rm -i -v uploads-production-documents:/dst alpine sh -c 'rm -rf /dst/* && tar -xzf - -C /dst'"
+```
+
+### 2c. Restart + verify
+
+```bash
+ssh $NEW "docker start app-production"
+ssh $NEW "docker exec -u 0 app-production chown -R 1001:1001 /app/media /app/documents"
+ssh $NEW "curl -sI http://127.0.0.1:3001 | head -1" # 200
+ssh $NEW "curl -sI http://127.0.0.1:3001/admin | head -1" # 200/30x
+```
+
+Optionally browse the site through an SSH tunnel before cutover:
+`ssh -L 3001:127.0.0.1:3001 $NEW`, then open http://localhost:3001 — content,
+images, and admin login (staging credentials) must all work.
+
+## Phase 3 — Cutover
+
+1. **Content freeze**: ask the editors to stop editing (~30 min window).
+2. Re-run all of Phase 2 (final sync).
+3. Switch the DNS A records for apex + `www` to the production IP.
+4. Kick Caddy so it retries ACME immediately, and watch until certs issue:
+ ```bash
+ ssh $NEW "systemctl restart caddy && journalctl -u caddy -f"
+ ```
+5. Verify from outside:
+ ```bash
+ curl -sI https://hl-mutter-teresa-chemnitz.de | head -1 # 200
+ curl -sI https://www.hl-mutter-teresa-chemnitz.de | head -3 # 308 → apex
+ ```
+ Spot-check pages, images, admin login, and (if feasible) a contact form email.
+
+Expected downtime ≈ DNS TTL (≤5 min) plus seconds of cert issuance. Visitors
+still resolving the old IP keep getting the identical just-synced content from
+the old server, so the window is soft.
+
+## Phase 4 — Post-cutover
+
+1. Stop the old server from answering/renewing the domain — the countdown
+ config is already removed from the repo, so re-rendering its Caddyfile drops
+ the domain:
+ ```bash
+ cd infra/ansible
+ ansible-playbook playbooks/setup.yml -i inventory/test.yml --vault-password-file ~/.config/chemnitz-vault-pass
+ ssh $OLD "rm -rf /var/www/countdown"
+ ```
+ > Do **not** run this before the DNS switch — it takes the live domain off
+ > the old server.
+2. Restore the DNS TTL to its normal value.
+3. Confirm the deployed build has the correct canonical URL: page source /
+ sitemap should reference `hl-mutter-teresa-chemnitz.de`, not `skick.app`.
+
+## Optional — minimal daily backup (recommended)
+
+On the production server, as root (`crontab -e`):
+
+```cron
+30 3 * * * mkdir -p /opt/backups && docker exec postgres pg_dump -U postgres -Fc church_website > /opt/backups/db-$(date +\%F).dump && docker run --rm -v uploads-production-media:/m:ro -v uploads-production-documents:/d:ro -v /opt/backups:/b alpine tar -czf /b/uploads-$(date +\%F).tar.gz -C / m d && find /opt/backups -mtime +7 -delete
+```
+
+Keeps 7 days of DB dumps + upload tarballs in `/opt/backups`. Off-site copies
+(e.g. rsync to the old server or your machine) are a possible next step.
+
+## Ongoing: deploying to production
+
+```bash
+cd infra/ansible
+ansible-playbook playbooks/deploy-production.yml -i inventory/production.yml --vault-password-file ~/.config/chemnitz-vault-pass
+```
+
+Production tracks the `staging` branch on git.skick.app.
diff --git a/launch.md b/launch.md
deleted file mode 100644
index e10fe12..0000000
--- a/launch.md
+++ /dev/null
@@ -1,13 +0,0 @@
-# Launch countdown
-
-## Switch to live
-
-```bash
-cd infra/ansible && ansible-playbook playbooks/switch-to-staging.yml -i inventory/test.yml --vault-password-file /home/benno/.config/chemnitz-vault-pass -e chemnitz_live=true
-```
-
-## Switch to countdown
-
-```bash
-cd infra/ansible && ansible-playbook playbooks/switch-to-staging.yml -i inventory/test.yml --vault-password-file /home/benno/.config/chemnitz-vault-pass -e chemnitz_live=false
-```
\ No newline at end of file
diff --git a/sites/chemnitz/config.ts b/sites/chemnitz/config.ts
index 94a3373..a821986 100644
--- a/sites/chemnitz/config.ts
+++ b/sites/chemnitz/config.ts
@@ -6,7 +6,7 @@ const config: SiteConfig = {
shortName: 'Hl. Mutter Teresa',
description:
'Katholische Pfarrei Heilige Mutter Teresa in Chemnitz – Gottesdienste, Veranstaltungen, Sakramente und Gemeindeleben.',
- url: 'https://mutter-teresa-chemnitz.de',
+ url: 'https://hl-mutter-teresa-chemnitz.de',
email: 'chemnitz@pfarrei-bddmei.de',
address: 'Hohe Straße 1',
postalCode: '09112',