skills/deploy-server/references/digitalocean.md

# DigitalOcean-Referenz

Alle Snippets verwenden `curl` + `python3` (keine externen Abhängigkeiten). Setze `DO_TOKEN`, bevor du sie ausführst.

**Shell-Kompatibilität:** Alle `python3 -c`\-Argumente stehen in einfachen Anführungszeichen, damit weder bash noch zsh Variablen ersetzt. Der Python-Code verwendet nur doppelte Anführungszeichen.

**DO\_TOKEN setzen:** In zsh funktioniert `DO_TOKEN=... curl ...` in einer Zeile NICHT: `$DO_TOKEN` wird ersetzt, bevor die Zuweisung greift. Nimm eine dieser Varianten:

```
export DO_TOKEN=...          # then run snippets normally
DO_TOKEN=...; curl ...       # semicolon separates assignment from command
```

**Fehlerbehandlung:** Alle Snippets prüfen auf DO-API-Fehler, bevor sie auf Daten zugreifen. DO-Fehler haben das Format `{"id": "...", "message": "..."}`.

## Instanzgrößen

Verfügbare Größen auflisten und filtern:

```
curl -sS -H "Authorization: Bearer $DO_TOKEN" \
  "https://api.digitalocean.com/v2/sizes?per_page=200" | python3 -c '
import json, sys
data = json.load(sys.stdin)
if "sizes" not in data:
    print("Error:", data.get("message", data)); sys.exit(1)
region, vcpus, mem_gb = "nyc3", 2, 2
for s in sorted(data["sizes"], key=lambda x: (x["vcpus"], x["memory"], x["price_monthly"])):
    if not s["available"] or region not in s["regions"]:
        continue
    if s["vcpus"] == vcpus and s["memory"] == mem_gb * 1024:
        slug, vc, mem, disk, price = s["slug"], s["vcpus"], s["memory"]/1024, s["disk"], s["price_monthly"]
        print(f"{slug:30s} {vc}vCPU {mem:.0f}GB {disk}GB ${price}/mo")
'
```

Passe `region`, `vcpus` und `mem_gb` nach Bedarf an. Verwende `>=` statt `==`, wenn es um einen Mindestwert gehen soll.

## Images finden

Den Slug der neuesten Ubuntu LTS ermitteln:

```
curl -sS -H "Authorization: Bearer $DO_TOKEN" \
  "https://api.digitalocean.com/v2/images?type=distribution&per_page=200" | python3 -c '
import json, sys
data = json.load(sys.stdin)
if "images" not in data:
    print("Error:", data.get("message", data)); sys.exit(1)
ubuntu = [i for i in data["images"] if i["distribution"] == "Ubuntu" and "LTS" in i.get("description", "")]
ubuntu.sort(key=lambda x: x["slug"], reverse=True)
for i in ubuntu[:5]:
    slug, desc = i["slug"], i["description"]
    print(f"{slug:30s} {desc}")
'
```

## Regionen finden

```
curl -sS -H "Authorization: Bearer $DO_TOKEN" \
  "https://api.digitalocean.com/v2/regions" | python3 -c '
import json, sys
data = json.load(sys.stdin)
if "regions" not in data:
    print("Error:", data.get("message", data)); sys.exit(1)
for r in data["regions"]:
    if r["available"]:
        slug, name = r["slug"], r["name"]
        print(f"{slug:10s} {name}")
'
```

## Standardprofil für Droplets

-   Region: `nyc3`
-   Image: neueste Ubuntu LTS, die über die API verfügbar ist
-   Plan: Basic / Regular (Shared CPU)
-   Beispielgröße: 2 vCPU / 4 GB RAM / 80 GB SSD / 4 TB Transfer (ca. 24 $ pro Monat)
-   Name: `wlsly-<adjective>-<surname>` (erzeugt mit dem Namensgenerator weiter unten)

## SSH-Schlüssel

Schlüssel auflisten:

```
curl -sS -H "Authorization: Bearer $DO_TOKEN" \
  "https://api.digitalocean.com/v2/account/keys?per_page=200" | python3 -c '
import json, sys
data = json.load(sys.stdin)
if "ssh_keys" not in data:
    print("Error:", data.get("message", data)); sys.exit(1)
for k in data["ssh_keys"]:
    kid, name, fp = k["id"], k["name"], k["fingerprint"]
    print(f"{kid:>10}  {name:30s}  {fp}")
'
```

Neuen Schlüssel hochladen:

```
curl -sS -X POST \
  -H "Authorization: Bearer $DO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "wlycli", "public_key": "'"$(cat ~/.ssh/wlsly_do.pub)"'" }' \
  "https://api.digitalocean.com/v2/account/keys"
```

## Projekte

Projekte auflisten:

```
curl -sS -H "Authorization: Bearer $DO_TOKEN" \
  "https://api.digitalocean.com/v2/projects?per_page=200" | python3 -c '
import json, sys
data = json.load(sys.stdin)
if "projects" not in data:
    print("Error:", data.get("message", data)); sys.exit(1)
for p in data["projects"]:
    default = " (default)" if p.get("is_default") else ""
    pid, name = p["id"], p["name"]
    print(f"{pid}  {name}{default}")
'
```

Ressourcen einem Projekt zuweisen:

```
curl -sS -X POST \
  -H "Authorization: Bearer $DO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "resources": ["do:droplet:<droplet-id>", "do:dbaas:<db-id>"] }' \
  "https://api.digitalocean.com/v2/projects/<project-id>/resources"
```

## URNs für Projektressourcen

-   Droplet: `do:droplet:<id>`
-   Verwaltete DB: `do:dbaas:<id>`

## Namensgenerator

Erzeuge einen zufälligen Namen im Docker-Stil mit dem Präfix `wlsly-` für Droplets und Datenbanken:

```
WLSLY_NAME=wlsly-$(shuf -n1 -e agile bold brave bright calm clever cool daring eager fair fast fierce fond frank gifted glad grand happy hardy keen kind lively lucid merry modest noble pensive proud quick quiet sharp shrewd sleek smart solid steady stoic sunny swift tender vivid warm wise witty zen)-$(shuf -n1 -e agnesi babbage bell bohr brahmagupta buck carson chatelet chebyshev curie darwin dijkstra easley einstein euler fermat feynman galois gauss goldberg goodall hamilton hawking hodgkin hopper hypatia jackson johnson kare keller knuth leavitt lovelace lumiere mahavira mandela mccarthy meitner mendel mirzakhani morse nash neumann nightingale noether northcutt panini pare pascal pasteur pike poincare ramanujan ride ritchie rosalind sagan shannon snyder swartz tesla thompson turing villani wescoff wing wozniak wright yalow)
echo "$WLSLY_NAME"
```

Verwende denselben erzeugten Namen für das Droplet und seine verwaltete DB (falls du eine anlegst).

## Droplet erstellen

```
curl -sS -X POST \
  -H "Authorization: Bearer $DO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name":"'"$WLSLY_NAME"'",
    "region":"<region>",
    "size":"<size-slug>",
    "image":"<ubuntu-image-slug>",
    "ssh_keys":["<fingerprint>"],
    "monitoring":true,
    "tags":["wlsly"]
  }' \
  "https://api.digitalocean.com/v2/droplets" | python3 -c '
import json, sys
data = json.load(sys.stdin)
if "droplet" not in data:
    print("Error:", data.get("message", data)); sys.exit(1)
d = data["droplet"]
print("Droplet ID:", d["id"])
print("Name:", d["name"])
print("Status:", d["status"])
'
```

## Droplet nach der IP abfragen

Das Droplet hat nicht sofort eine IP. Frage so lange ab, bis `status=active` gilt:

```
curl -sS -H "Authorization: Bearer $DO_TOKEN" \
  "https://api.digitalocean.com/v2/droplets/<droplet-id>" | python3 -c '
import json, sys
data = json.load(sys.stdin)
if "droplet" not in data:
    print("Error:", data.get("message", data)); sys.exit(1)
d = data["droplet"]
print("Status:", d["status"])
for net in d.get("networks", {}).get("v4", []):
    print("IPv4 (%s): %s" % (net["type"], net["ip_address"]))
'
```

## DNS (von DigitalOcean verwaltet)

Wenn DigitalOcean das DNS verwaltet, kann der Agent Einträge per API anlegen oder aktualisieren, sobald das Droplet eine IP hat.

**Prüfen, ob die Domain bereits in DO existiert:**

```
curl -sS -H "Authorization: Bearer $DO_TOKEN" \
  "https://api.digitalocean.com/v2/domains/<domain>" | python3 -c '
import json, sys
data = json.load(sys.stdin)
if "domain" in data:
    print("Domain: %s (exists)" % data["domain"]["name"])
else:
    print("Domain not found - needs to be created")
    if "message" in data:
        print("API:", data["message"])
'
```

**Domain anlegen (nur einmal, falls sie nicht gefunden wurde):**

```
curl -sS -X POST \
  -H "Authorization: Bearer $DO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"<domain>"}' \
  "https://api.digitalocean.com/v2/domains"
```

**Vorhandene Einträge einer Domain auflisten:**

```
curl -sS -H "Authorization: Bearer $DO_TOKEN" \
  "https://api.digitalocean.com/v2/domains/<domain>/records?per_page=200" | python3 -c '
import json, sys
data = json.load(sys.stdin)
if "domain_records" not in data:
    print("Error:", data.get("message", data)); sys.exit(1)
for r in data["domain_records"]:
    rid, rtype, name, rdata = r["id"], r["type"], r["name"], r["data"]
    print(f"{rid:>10}  {rtype:6s}  {name:30s}  {rdata}")
'
```

**A-Eintrag für die Root-Domain (`@`) anlegen:**

```
curl -sS -X POST \
  -H "Authorization: Bearer $DO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type":"A","name":"@","data":"<droplet-ip>","ttl":300}' \
  "https://api.digitalocean.com/v2/domains/<domain>/records"
```

**A-Eintrag für die Subdomain `storage` anlegen (erforderlich, wenn kein CDN genutzt wird):**

```
curl -sS -X POST \
  -H "Authorization: Bearer $DO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type":"A","name":"storage","data":"<droplet-ip>","ttl":300}' \
  "https://api.digitalocean.com/v2/domains/<domain>/records"
```

**Bestehenden Eintrag aktualisieren (falls er schon existiert):**

```
curl -sS -X PUT \
  -H "Authorization: Bearer $DO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"data":"<droplet-ip>"}' \
  "https://api.digitalocean.com/v2/domains/<domain>/records/<record-id>"
```

Bevor certbot standalone läuft, stelle sicher, dass die A/AAAA-Einträge für `<domain>` (und `storage.<domain>`, falls verwendet) auf die Droplet-IP zeigen. CLI-Flags: `--public-ip` (überschreibt die erkannte IP) und `--skip-dns-preflight` (überspringt die Prüfung). Wenn du für ein temporäres Setup selbst signierte Zertifikate brauchst, gib `--self-signed-certs` an (erzeugt selbst signierte Zertifikate).

## Hinweise zu DNS

Ist kein CDN konfiguriert, nutzt die Plattform `storage.<domain>`. Verwende A/AAAA auf die Droplet-IP oder CNAME auf die Root-Domain, wenn dein DNS-Anbieter das erlaubt.

## Host einrichten (SSH)

**Wichtig:** Verwende zum Ausführen mehrzeiliger Skripte auf dem Server immer `ssh host 'bash -s' << 'EOF'` (Heredoc in Anführungszeichen). Übergib Skripte nie als `ssh host "..."`\-Argumente in doppelten Anführungszeichen: Die lokale Shell (vor allem zsh) ersetzt `$(...)`, `[...]`\-Muster und `$VARIABLES`, bevor die Skripte an den entfernten Host gesendet werden.

```
ssh -o StrictHostKeyChecking=accept-new root@<droplet-ip> 'bash -s' << 'PROVISION'
set -euo pipefail

adduser --disabled-password --gecos "Welley Service" wlsly
usermod -aG sudo wlsly
echo "wlsly ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/wlsly
chmod 440 /etc/sudoers.d/wlsly

mkdir -p /home/wlsly/.ssh
cp /root/.ssh/authorized_keys /home/wlsly/.ssh/authorized_keys
chown -R wlsly:wlsly /home/wlsly/.ssh
chmod 700 /home/wlsly/.ssh
chmod 600 /home/wlsly/.ssh/authorized_keys

apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

usermod -aG docker wlsly

echo "=== Verify ==="
docker --version
docker compose version
id wlsly
PROVISION
```

Wechsle dann zum Nutzer, der nicht root ist:

```
ssh -o StrictHostKeyChecking=accept-new wlsly@<droplet-ip> 'echo "Connected as $(whoami)"'
```

## Verwaltetes Postgres (DigitalOcean)

Die Zugangsdaten stehen erst _nach_ dem Anlegen fest. Lege zuerst die DB an und lies dann die Verbindungsdaten aus.

**Anlegen:**

```
curl -sS -X POST \
  -H "Authorization: Bearer $DO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name":"'"$WLSLY_NAME"'",
    "engine":"pg",
    "version":"16",
    "region":"nyc3",
    "size":"db-s-1vcpu-2gb",
    "num_nodes":1
  }' \
  "https://api.digitalocean.com/v2/databases" | python3 -c '
import json, sys
data = json.load(sys.stdin)
if "database" not in data:
    print("Error:", data.get("message", data)); sys.exit(1)
db = data["database"]
print("DB ID:", db["id"])
print("Status:", db["status"])
'
```

**Abfragen, bis die DB online ist** (dauert 3 bis 5 Minuten):

```
curl -sS -H "Authorization: Bearer $DO_TOKEN" \
  "https://api.digitalocean.com/v2/databases/<db-id>" | python3 -c '
import json, sys
data = json.load(sys.stdin)
if "database" not in data:
    print("Error:", data.get("message", data)); sys.exit(1)
db = data["database"]
print("Status:", db["status"])
if db["status"] == "online":
    conn = db.get("connection", {})
    priv = db.get("private_connection", {})
    print("Host:", conn.get("host"))
    print("Private host:", priv.get("host"))
    print("Port:", conn.get("port"))
    print("Database:", conn.get("database"))
    print("User:", conn.get("user"))
'
```

**Passwort des DB-Nutzers abrufen** (das Passwort gibt es nur über diesen Endpoint):

```
curl -sS -H "Authorization: Bearer $DO_TOKEN" \
  "https://api.digitalocean.com/v2/databases/<db-id>/users" | python3 -c '
import json, sys
data = json.load(sys.stdin)
if "users" not in data:
    print("Error:", data.get("message", data)); sys.exit(1)
for u in data["users"]:
    print("User:", u["name"], "Password:", u.get("password", "N/A"), "Role:", u.get("role", "N/A"))
'
```

Verwende die zurückgegebenen Werte für Host, Port, Datenbank, Nutzer und Passwort mit: `--postgres-host`, `--postgres-port`, `--postgres-database`, `--postgres-user`, `--postgres-password`.

Fehlt das Passwort oder ist es abgelaufen, setze die Authentifizierung des DB-Nutzers zurück.

## Deployment mit der Welley CLI (Beispiele)

Einfaches Beispiel mit Secrets:

```
wlycli deployment create \
  --domain=example.com \
  --email=admin@example.com \
  --update-period=15min \
  --env=stage \
  --otel-endpoint=https://otlp-gateway-prod-us-east-2.grafana.net/otlp \
  --otel-use-grpc=false \
  --otel-headers='<OTEL_AUTH_HEADERS>' \
  --smtp-host=email-smtp.us-east-2.amazonaws.com \
  --smtp-user=<MAIL_USER> \
  --smtp-password=<MAIL_PASSWORD> \
  --smtp-from=noreply@mail.example.com \
  --language-service=internal \
  --translation-service=deepl \
  --translation-service-api-key=<DEEPL_KEY> \
  --with-sentry
```

Externes Postgres:

```
wlycli deployment create \
  --domain=example.com \
  --email=admin@example.com \
  --postgres-host=<DB_HOST> \
  --postgres-port=<DB_PORT> \
  --postgres-user=<DB_USER> \
  --postgres-database=<DB_NAME> \
  --postgres-password=<DB_PASSWORD>
```

Bevor du certbot ausführst, stelle sicher, dass die DNS-A/AAAA-Einträge für `<domain>` (und `storage.<domain>`, falls verwendet) auf die Droplet-IP zeigen.

## Weitere Dokumente

-   [**Welley CLI** docs/index.md](https://welley.com/de/administrators/docs/cli/)
-   [**Schnellstart: Minimales Welley-Deployment** docs/quick-start.md](https://welley.com/de/administrators/docs/quick-start/)
-   [**Server bereitstellen** skills/deploy-server/SKILL.md](https://welley.com/de/administrators/docs/deploy-server-skill/)
-   [**openai.yaml** skills/deploy-server/agents/openai.yaml](https://welley.com/de/administrators/docs/deploy-server-openai-yaml/)
