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',