diff --git a/.env.example b/.env.example
index 779bd6b..da70187 100644
--- a/.env.example
+++ b/.env.example
@@ -12,6 +12,8 @@ FTP_USER=
FTP_PASSWORD=
# Relativer Pfad zum Document Root (in Plesk prüfen, typisch httpdocs)
FTP_REMOTE_PATH=httpdocs
+# Ausserhalb Document Root — nicht öffentlich (config, .env, data)
+FTP_PRIVATE_PATH=private
# --- Domain & Server ---
DOMAIN=0012.de
diff --git a/HOSTING.md b/HOSTING.md
index 53e92a1..d65dae3 100644
--- a/HOSTING.md
+++ b/HOSTING.md
@@ -42,6 +42,26 @@ DNS-Einträge nur für Domains mit Bezug zum Paket oder über wint.global bestel
Domain-Delegation kann nach Registration bis zu **24 Stunden** dauern. Bis dahin ggf. temporäre URL aus dem Kundenbereich nutzen.
+## TLS / HTTPS
+
+Konfiguration in Plesk (Stand 2026-06-11):
+
+| | |
+|---|---|
+| Zertifikat | Let's Encrypt (`CN=0012.de`) |
+| Gültig | 2026-06-11 — 2026-09-09 (Auto-Renewal in Plesk) |
+| HTTP → HTTPS | 301 Permanent Redirect |
+| HSTS | `Strict-Transport-Security: max-age=15768000; includeSubDomains` (~182 Tage) |
+
+Prüfung:
+
+```bash
+curl -sI http://0012.de/ # → 301 Location: https://0012.de/
+curl -sI https://0012.de/ # → 200, HSTS-Header
+```
+
+**PHP (Web):** 8.3.31 (laut Response-Header). Cron-Aufgaben können abweichende Version wählen (z. B. 8.5.x).
+
## E-Mail (Plesk)
| | |
diff --git a/README.md b/README.md
index d48ffc7..840bb23 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,10 @@ Der Webspace sieht boka **von außen** — interne Metriken brauchen das Push-Sk
│ ├── index.php # Status-Beacon Landing
│ ├── monitor.php # Cron: Pull-Checks
│ └── ingest.php # Empfang Push von boka
-├── config/ # PHP-Konfiguration (lädt .env)
+├── config/ # PHP-Konfiguration → deploy nach private/config/
+├── scripts/
+│ ├── deploy.sh # FTP-Deploy (ruft deploy.py auf)
+│ └── deploy.py
└── todo/ # Aufgabenliste
```
@@ -67,6 +70,6 @@ Offene Arbeiten: [`todo/README.md`](todo/README.md)
## Schnellstart (wenn eingerichtet)
1. `.env.example` → `.env` kopieren und ausfüllen
-2. Dateien nach `httpdocs/` deployen (FTP/rsync)
+2. `./scripts/deploy.sh` — spielt `public/` nach `httpdocs/`, Config und `.env` nach `private/`
3. In Plesk: geplante Aufgabe für `monitor.php` (Cron-Stil, z. B. `*/10 * * * *`)
4. Auf boka: Push-Skript + Crontab (siehe `todo/04-monitoring-push-boka.md`)
diff --git a/config/bootstrap.php b/config/bootstrap.php
new file mode 100644
index 0000000..fb707f0
--- /dev/null
+++ b/config/bootstrap.php
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+ = htmlspecialchars($domain, ENT_QUOTES, 'UTF-8') ?> · external observer
+
+
+
+
+ 0012
+ external observer node · watching from outside
+
+
+
+
Monitoring active — last check = htmlspecialchars($lastRun, ENT_QUOTES, 'UTF-8') ?>
+
+
Monitoring not initialized — awaiting first cron run
+
+
+ Node = htmlspecialchars($domain, ENT_QUOTES, 'UTF-8') ?>
+ · independent of boka (= htmlspecialchars($bokaIp, ENT_QUOTES, 'UTF-8') ?>)
+
+
+
+
+
diff --git a/scripts/__pycache__/deploy.cpython-314.pyc b/scripts/__pycache__/deploy.cpython-314.pyc
new file mode 100644
index 0000000..4313351
Binary files /dev/null and b/scripts/__pycache__/deploy.cpython-314.pyc differ
diff --git a/scripts/deploy.py b/scripts/deploy.py
new file mode 100755
index 0000000..920c3a4
--- /dev/null
+++ b/scripts/deploy.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""Deploy public/, config/, and .env to wint.global / Plesk via FTP/FTPS."""
+
+from __future__ import annotations
+
+import argparse
+import ftplib
+import os
+import sys
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def load_env(path: Path) -> dict[str, str]:
+ if not path.is_file():
+ sys.exit(f"Missing {path} — copy .env.example to .env and fill in credentials.")
+
+ env: dict[str, str] = {}
+ for line in path.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ key, _, value = line.partition("=")
+ env[key.strip()] = value.strip()
+ return env
+
+
+def require_env(env: dict[str, str], *keys: str) -> None:
+ missing = [k for k in keys if not env.get(k)]
+ if missing:
+ sys.exit(f"Missing in .env: {', '.join(missing)}")
+
+
+def connect(env: dict[str, str]) -> ftplib.FTP:
+ host = env["FTP_HOST"]
+ user = env["FTP_USER"]
+ password = env["FTP_PASSWORD"]
+
+ try:
+ ftp = ftplib.FTP_TLS(host, timeout=60)
+ ftp.login(user, password)
+ ftp.prot_p()
+ print("Connected (FTPS)")
+ return ftp
+ except ftplib.all_errors as exc:
+ print(f"FTPS failed ({exc}), trying plain FTP …")
+ ftp = ftplib.FTP(host, timeout=60)
+ ftp.login(user, password)
+ print("Connected (FTP)")
+ return ftp
+
+
+def cwd_to_root(ftp: ftplib.FTP) -> None:
+ try:
+ ftp.cwd("/")
+ except ftplib.error_perm:
+ pass
+
+
+def ensure_remote_dir(ftp: ftplib.FTP, remote_dir: str) -> None:
+ cwd_to_root(ftp)
+ for part in [p for p in remote_dir.split("/") if p]:
+ try:
+ ftp.cwd(part)
+ except ftplib.error_perm:
+ ftp.mkd(part)
+ ftp.cwd(part)
+
+
+def upload_file(ftp: ftplib.FTP, local: Path, remote_path: str) -> None:
+ remote_path = remote_path.replace("\\", "/").lstrip("/")
+ remote_dir = os.path.dirname(remote_path)
+ remote_name = os.path.basename(remote_path)
+
+ if remote_dir:
+ ensure_remote_dir(ftp, remote_dir)
+ else:
+ cwd_to_root(ftp)
+
+ with local.open("rb") as handle:
+ ftp.storbinary(f"STOR {remote_name}", handle)
+
+ print(f" ↑ {local.relative_to(ROOT)} → {remote_path}")
+
+
+def upload_tree(ftp: ftplib.FTP, local_dir: Path, remote_base: str) -> None:
+ remote_base = remote_base.strip("/")
+ for path in sorted(local_dir.rglob("*")):
+ if path.is_file():
+ rel = path.relative_to(local_dir).as_posix()
+ upload_file(ftp, path, f"{remote_base}/{rel}")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Deploy 0012.de webspace")
+ parser.add_argument("--dry-run", action="store_true", help="Show plan only")
+ args = parser.parse_args()
+
+ env = load_env(ROOT / ".env")
+ require_env(env, "FTP_HOST", "FTP_USER", "FTP_PASSWORD")
+
+ remote_web = env.get("FTP_REMOTE_PATH", "httpdocs").strip("/")
+ remote_private = env.get("FTP_PRIVATE_PATH", "private").strip("/")
+
+ print("Deploy plan:")
+ public_files = sum(1 for p in (ROOT / "public").rglob("*") if p.is_file())
+ config_files = sum(1 for p in (ROOT / "config").rglob("*") if p.is_file())
+ print(f" public/ ({public_files} files) → {remote_web}/")
+ print(f" config/ ({config_files} files) → {remote_private}/config/")
+ print(f" .env → {remote_private}/.env")
+ print(f" (mkdir) {remote_private}/data/")
+
+ if args.dry_run:
+ return
+
+ print("\nConnecting …")
+ ftp = connect(env)
+ try:
+ print("Uploading …")
+ upload_tree(ftp, ROOT / "public", remote_web)
+ upload_tree(ftp, ROOT / "config", f"{remote_private}/config")
+ ensure_remote_dir(ftp, f"{remote_private}/data")
+ upload_file(ftp, ROOT / ".env", f"{remote_private}/.env")
+ print("\nDone.")
+ finally:
+ ftp.quit()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
new file mode 100755
index 0000000..a1453cb
--- /dev/null
+++ b/scripts/deploy.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+set -euo pipefail
+exec python3 "$(dirname "$0")/deploy.py" "$@"
diff --git a/todo/01-bootstrap-env-deploy.md b/todo/01-bootstrap-env-deploy.md
index 1fc73ae..383b97d 100644
--- a/todo/01-bootstrap-env-deploy.md
+++ b/todo/01-bootstrap-env-deploy.md
@@ -1,6 +1,7 @@
# 01 — Bootstrap, `.env`, Deployment
**Priorität:** high
+**Status:** erledigt (2026-06-11)
**Abhängigkeiten:** keine
**Blockiert:** 02, 03, 04, 05, 06
@@ -10,22 +11,38 @@ Lokales Repo mit dem Webspace verbinden und eine deploybare Grundstruktur anlege
## Checkliste
-- [ ] `.env.example` nach `.env` kopieren
-- [ ] Plesk-User/Passwort, FTP, später SMTP in `.env` eintragen (nicht committen)
-- [ ] In Plesk Document Root prüfen (typisch `httpdocs/`)
-- [ ] PHP-Version notieren (Cron-Screenshot: 8.5.x)
-- [ ] Verzeichnis `public/` als Quelle für `httpdocs/` festlegen
-- [ ] Erstes Test-Deployment: `public/index.php` mit `phpinfo()` oder „hello“ — danach wieder entfernen
-- [ ] HTTPS für `0012.de` prüfen (Let's Encrypt in Plesk)
-- [ ] Optional: Deploy-Skript (`scripts/deploy.sh` rsync/lftp) — nur wenn gewünscht
+- [x] `.env.example` nach `.env` kopieren
+- [x] Plesk-User/Passwort, FTP in `.env` eintragen (nicht committen)
+- [x] Document Root: `httpdocs/` (Plesk-Standard)
+- [x] PHP-Version: 8.5.x (Plesk Cron)
+- [x] Verzeichnis `public/` als Quelle für `httpdocs/`
+- [x] Erstes Deployment: `public/index.php` (Status-Beacon Platzhalter)
+- [x] HTTPS Let's Encrypt in Plesk (gültig bis 2026-09-09)
+- [x] HTTP → HTTPS Redirect (301)
+- [x] HSTS (`Strict-Transport-Security`, max-age 15768000, includeSubDomains)
+- [x] Deploy-Skript: `scripts/deploy.sh` (FTPS/FTP via Python)
+
+## Server-Layout (nach Deploy)
+
+```
+0012.de/
+├── httpdocs/ ← public/
+│ └── index.php
+└── private/ ← nicht öffentlich
+ ├── .env
+ ├── config/bootstrap.php
+ └── data/ ← später last-checks.json
+```
+
+## Deploy
+
+```bash
+./scripts/deploy.sh # upload
+./scripts/deploy.sh --dry-run
+```
## Akzeptanzkriterien
-- FTP-Login funktioniert
-- `https://0012.de/` liefert eine Testseite
-- `.env` liegt lokal, ist in `.gitignore`
-
-## Notizen
-
-- Temporäre URL aus Kundenbereich nutzen, falls DNS noch propagiert
-- Firewall: Port 8443 (Plesk), 21/22 (FTP/SFTP je nach Anbieter)
+- [x] FTP-Login funktioniert (deploy.sh)
+- [x] `https://0012.de/` — Landing Page über TLS
+- [x] `.env` liegt lokal, ist in `.gitignore`