---
title: Issue an ACME Wildcard Certificate
path: tutorials/acme-wildcard-cert
status: published
---

# Issue an ACME Wildcard Certificate

Get a Let's Encrypt wildcard certificate (`*.acme.example`) using DNS-01 challenges. Wildcards require DNS-01 — Let's Encrypt won't sign one via HTTP-01. ScaiVault handles the DNS dance automatically once you've connected a DNS provider.

## What you need

- A domain whose authoritative nameservers you control (or that's hosted on a [supported DNS provider](../reference/dns-providers)).
- A ScaiVault token with `pki:issue` and `pki:admin`.
- A reachable HTTPS endpoint for renewal-status webhooks (optional but recommended).

## 1. Configure a DNS provider

ScaiVault writes the `_acme-challenge` TXT record via this provider during the challenge. Use whichever your zone lives on; this example uses Route 53.

```bash
# Store AWS credentials in ScaiVault
curl -X PUT https://scaivault.scailabs.ai/v1/secrets/infra/aws/route53-acme \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "access_key_id": "AKIA...",
      "secret_access_key": "..."
    },
    "secret_type": "json"
  }'

# Configure the DNS provider
curl -X POST https://scaivault.scailabs.ai/v1/dns/providers \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "route53-acme",
    "provider_type": "route53",
    "credentials": {
      "access_key_id": "AKIA...",
      "secret_access_key": "..."
    },
    "managed_zones": ["acme.example", "*.acme.example"],
    "verify": true
  }'
# -> {"id": "dnsp_abc", "is_active": true, ...}
```

The IAM credentials need a minimum policy — see [DNS Providers Reference](../reference/dns-providers#aws-route-53). The `verify: true` flag refuses to create the provider if it can't list zones, so you'll know immediately if the credentials are wrong.

## 2. Register an ACME account

Use the **staging** environment for your first attempt — Let's Encrypt's staging server has looser rate limits and you don't want to burn through your production quota debugging signatures.

```bash
curl -X POST https://scaivault.scailabs.ai/v1/pki/acme/accounts \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "letsencrypt-staging",
    "provider": "letsencrypt",
    "environment": "staging",
    "email": "certs@acme.example"
  }'
# -> {"id": "acme_staging_abc", "status": "active", ...}
```

ScaiVault generates the ACME account key and registers with Let's Encrypt. The account key stays in ScaiVault.

## 3. Issue a wildcard cert (staging)

```bash
curl -X POST https://scaivault.scailabs.ai/v1/pki/acme/issue \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "account_id": "acme_staging_abc",
    "domains": ["*.acme.example", "acme.example"],
    "challenge_type": "dns-01",
    "auto_renew": true
  }'
# -> {"order_id": "acme_order_xyz", "status": "pending", ...}
```

Including the apex `acme.example` alongside `*.acme.example` is common — wildcards don't match the apex, so cover it explicitly if you want both.

Poll:

```bash
while true; do
  status=$(curl -s -H "Authorization: Bearer $TOKEN" \
    https://scaivault.scailabs.ai/v1/pki/acme/orders/acme_order_xyz | jq -r '.status')
  echo "$(date +%H:%M:%S) $status"
  [ "$status" = "valid" ] && break
  [ "$status" = "invalid" ] && break
  sleep 5
done
```

Typical sequence:

- `pending` (~5s) — ScaiVault writes the TXT record.
- `pending` (~30-60s) — waiting for DNS propagation.
- `processing` (~5s) — telling ACME the challenge is ready.
- `valid` — done. The cert is issued.

Get the certificate:

```bash
order=$(curl -s -H "Authorization: Bearer $TOKEN" \
  https://scaivault.scailabs.ai/v1/pki/acme/orders/acme_order_xyz)
cert_id=$(echo "$order" | jq -r '.certificate_id')

curl -s -H "Authorization: Bearer $TOKEN" \
  https://scaivault.scailabs.ai/v1/pki/certificates/$cert_id/pem \
  | jq -r '.certificate_pem'
```

The cert chains back to Let's Encrypt's staging root — your browser will reject it as untrusted. That's expected; staging certs are intentionally not trusted by public roots.

## 4. Promote to production

Repeat steps 2 and 3 with `environment: "production"`. Same code, real cert.

```bash
curl -X POST https://scaivault.scailabs.ai/v1/pki/acme/accounts \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "letsencrypt-production",
    "provider": "letsencrypt",
    "environment": "production",
    "email": "certs@acme.example"
  }'

curl -X POST https://scaivault.scailabs.ai/v1/pki/acme/issue \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "account_id": "acme_production_abc",
    "domains": ["*.acme.example", "acme.example"],
    "challenge_type": "dns-01",
    "auto_renew": true
  }'
```

Watch out for Let's Encrypt's rate limits — 50 certs per registered domain per week. If you're testing in production and hit this, switch back to staging.

## 5. Get the cert into your edge

For an ingress (nginx, Traefik, HAProxy) running in Kubernetes, fetch the cert at startup and refresh periodically. A small init script and CronJob:

```bash
#!/bin/sh
# fetch-cert.sh — write the current ACME cert to disk

CERT_ID="${CERT_ID:?required}"  # known after first issuance

resp=$(curl -fsS -H "Authorization: Bearer $SCAIVAULT_TOKEN" \
  "https://scaivault.scailabs.ai/v1/pki/certificates/$CERT_ID/pem")

mkdir -p /etc/tls
echo "$resp" | jq -r '.certificate_pem' > /etc/tls/fullchain.pem

# Get the private key — only works if role stores keys, or if ACME-issued
curl -fsS -X POST -H "Authorization: Bearer $SCAIVAULT_TOKEN" \
  "https://scaivault.scailabs.ai/v1/pki/certificates/$CERT_ID/private-key" \
  | jq -r '.private_key_pem' > /etc/tls/privkey.pem

chmod 600 /etc/tls/privkey.pem

# Reload nginx if it's running
[ -f /var/run/nginx.pid ] && nginx -s reload
```

Run this on a CronJob every 6 hours. Idempotent — same cert, no-op reload. When the underlying cert renews (~30 days before expiry), the next CronJob run picks up the new version and triggers a reload.

## 6. Auto-renewal verification

`auto_renew: true` means ScaiVault will renew automatically ~30 days before `not_after`. Subscribe to `certificate.renewed` events so you can confirm the renewal worked and your edge has refreshed:

```bash
curl -X POST https://scaivault.scailabs.ai/v1/subscriptions \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "wildcard-renewals",
    "paths": ["pki/certificates/cert_*"],
    "events": ["certificate.renewed", "certificate.renewal_failed"],
    "delivery": {
      "type": "webhook",
      "url": "https://ops.acme.example/scaivault/cert-events",
      "secret": "whsec_..."
    }
  }'
```

Alert on `certificate.renewal_failed`. ScaiVault retries on backoff, but persistent failures (DNS provider creds expired, Let's Encrypt rate-limit hit) need human intervention.

## 7. Force an early renewal (rare)

If you need to renew before the 30-day window — e.g., emergency rotation, an audit requirement — trigger it:

```bash
curl -X POST https://scaivault.scailabs.ai/v1/pki/certificates/cert_abc/renew \
  -H "Authorization: Bearer $TOKEN"
```

Same ACME flow, new cert issued. Watch out: every renewal counts against the Let's Encrypt rate limit.

## What you have now

- A wildcard cert covering `*.acme.example` and `acme.example`.
- Auto-renewal ~30 days before expiry, with event notification.
- A DNS provider configured to handle challenges for the zone.
- Edge servers reloading the cert without manual intervention.

## Common failure modes

**Order stays `pending` for minutes.** DNS propagation is slow. Check authoritative nameservers directly: `dig +short TXT _acme-challenge.acme.example @<authoritative-ns>`. If the record isn't there, your DNS provider integration didn't write it — check the provider with `POST /v1/dns/providers/{id}/verify` and the audit log for `dns_record_create`.

**Order goes `invalid` quickly.** Look at the order's `challenges[].error` field. Common: domain in the order isn't in any DNS provider's `managed_zones`. Add the zone to a provider.

**Rate-limit error from Let's Encrypt.** Switch back to staging, or wait. The error envelope tells you when you can retry: `"backend_rate_limited"` with `"retry_after"`.

**Wildcard doesn't work for the apex.** `*.acme.example` doesn't match `acme.example`. Include both in `domains`, as in step 3.

## What's next

- [PKI guide](../api-guides/pki-certificates) — internal CA hierarchy and mTLS.
- [DNS Providers reference](../reference/dns-providers) — every supported provider's config.
- [Webhook Signatures](../advanced/webhook-signatures) — signing details for the renewal subscription.
