skills/deploy-server/references/digitalocean.md

# Referencia de DigitalOcean

Todos los fragmentos usan `curl` + `python3` (sin dependencias externas). Define `DO_TOKEN` antes de ejecutarlos.

**Compatibilidad con el shell:** Todos los argumentos de `python3 -c` usan comillas simples, así que ni bash ni zsh expanden variables. El código Python usa solo comillas dobles.

**Cómo definir DO\_TOKEN:** En zsh, `DO_TOKEN=... curl ...` en una sola línea NO funciona: `$DO_TOKEN` se expande antes de que la asignación surta efecto. Usa una de estas opciones:

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

**Manejo de errores:** Todos los fragmentos comprueban si hay errores de la API de DO antes de acceder a los datos. Los errores de DO tienen el formato `{"id": "...", "message": "..."}`.

## Tamaños de instancia

Lista y filtra los tamaños disponibles:

```
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")
'
```

Ajusta `region`, `vcpus` y `mem_gb` según lo necesites. Usa `>=` en lugar de `==` para buscar tamaños que cumplan como mínimo esos valores.

## Descubrir imágenes

Obtén el slug de la última versión LTS de Ubuntu:

```
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}")
'
```

## Descubrir regiones

```
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}")
'
```

## Perfil base del droplet

-   Región: `nyc3`
-   Imagen: la última versión LTS de Ubuntu disponible en la API
-   Plan: Basic / Regular (Shared CPU)
-   Tamaño de ejemplo: 2 vCPU / 4 GB de RAM / 80 GB de SSD / 4 TB de transferencia (~$24/mes)
-   Nombre: `wlsly-<adjective>-<surname>` (generado con el generador de nombres que aparece más abajo)

## Claves SSH

Lista las claves:

```
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}")
'
```

Sube una clave nueva:

```
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"
```

## Proyectos

Lista los proyectos:

```
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}")
'
```

Asigna recursos a un proyecto:

```
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"
```

## URN de los recursos del proyecto

-   Droplet: `do:droplet:<id>`
-   Base de datos administrada: `do:dbaas:<id>`

## Generador de nombres

Genera un nombre aleatorio al estilo de Docker, con el prefijo `wlsly-`, para droplets y bases de datos:

```
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"
```

Usa el mismo nombre generado para el droplet y para su base de datos administrada (si la creas).

## Creación del droplet

```
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"])
'
```

## Consultar la IP del droplet

El droplet no tendrá una IP de inmediato. Consulta su estado hasta que aparezca `status=active`:

```
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 (administrado por DigitalOcean)

Si DigitalOcean administra el DNS, el agente puede crear o actualizar registros mediante la API cuando el droplet ya tenga una IP.

**Comprueba si el dominio ya existe en DO:**

```
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"])
'
```

**Crea un dominio (solo una vez, si no existe):**

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

**Lista los registros existentes de un dominio:**

```
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}")
'
```

**Crea un registro A para el dominio raíz (`@`):**

```
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"
```

**Crea un registro A para el subdominio `storage` (obligatorio cuando no hay CDN):**

```
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"
```

**Actualiza un registro existente (si el registro ya existe):**

```
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>"
```

Antes de ejecutar certbot en modo standalone, asegúrate de que los registros A/AAAA de `<domain>` (y de `storage.<domain>` si lo usas) apunten a la IP del droplet. Opciones de CLI: `--public-ip` (reemplaza la IP detectada) y `--skip-dns-preflight` (omite la comprobación). Si necesitas usar certificados autofirmados en una configuración temporal, pasa `--self-signed-certs` (genera certificados autofirmados).

## Notas sobre DNS

Si no hay un CDN configurado, la plataforma usa `storage.<domain>`. Usa registros A/AAAA hacia la IP del droplet, o un CNAME hacia el dominio raíz si tu proveedor de DNS lo permite.

## Aprovisionamiento del host (SSH)

**Importante:** Usa siempre `ssh host 'bash -s' << 'EOF'` (heredoc entre comillas) para ejecutar scripts de varias líneas de forma remota. Nunca pases los scripts como argumentos `ssh host "..."` entre comillas dobles: el shell local (sobre todo zsh) expandirá `$(...)`, los patrones `[...]` y `$VARIABLES` antes de enviarlos al host remoto.

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

Luego cambia al usuario que no es root:

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

## Postgres administrado (DigitalOcean)

Las credenciales solo se conocen _después_ de crear la base de datos. Primero crea la base de datos y luego lee los datos de conexión.

**Crear:**

```
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"])
'
```

**Consulta hasta que esté en línea** (tarda de 3 a 5 minutos):

```
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"))
'
```

**Obtén la contraseña del usuario de la base de datos** (la contraseña solo está disponible en este 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"))
'
```

Usa el host, el puerto, la base de datos, el usuario y la contraseña que obtuviste con estas opciones: `--postgres-host`, `--postgres-port`, `--postgres-database`, `--postgres-user`, `--postgres-password`.

Si falta la contraseña o ya venció, restablece la autenticación del usuario de la base de datos.

## Despliegue con Welley CLI (ejemplos)

Básico, con secretos:

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

Postgres externo:

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

Antes de ejecutar certbot, asegúrate de que los registros DNS A/AAAA de `<domain>` (y de `storage.<domain>` si lo usas) apunten a la IP del droplet.

## Otros documentos

-   [**Welley CLI** docs/index.md](https://welley.com/es/administrators/docs/cli/)
-   [**Inicio rápido: despliegue mínimo de Welley** docs/quick-start.md](https://welley.com/es/administrators/docs/quick-start/)
-   [**Desplegar el servidor** skills/deploy-server/SKILL.md](https://welley.com/es/administrators/docs/deploy-server-skill/)
-   [**openai.yaml** skills/deploy-server/agents/openai.yaml](https://welley.com/es/administrators/docs/deploy-server-openai-yaml/)
