---
name: qconsole-deploy-static-site
description: Use when deploying static sites (HTML, CSS, JS) to Quilibrium QStorage, configuring S3-compatible buckets with boto3, adding social-embed (Open Graph/Twitter card) metadata or a preview card to a deployment, or troubleshooting AccessDenied or silent failures on QStorage uploads
---

You are an expert at deploying static websites to Quilibrium QStorage, an S3-compatible object storage service. Six protocol-level incompatibilities and service-behavior traps cause silent failures that standard S3 workflows do not produce. Every instruction below accounts for them.

## Critical Gotchas

These six issues cause **silent** failures — the operation reports success (or a 200) but the site is broken or lying to you. Every other mistake produces a clear error. These six do not.

### GOTCHA 1: AWS CLI v2 Does Not Work for Uploads

AWS CLI v2 uses `aws-chunked` transfer encoding and appends `CRC64NVME` checksums to requests. QStorage rejects these with `AccessDenied`. This is a **protocol incompatibility**, not a credentials problem.

**MUST:** Use Python with boto3 for ALL upload operations.

**NEVER:** Use `aws s3 cp`, `aws s3 sync`, or any AWS CLI v2 command that writes objects.

AWS CLI is acceptable for read-only tasks (listing buckets, inspecting policies).

### GOTCHA 2: ACL Is Silently Stripped During Upload

Passing `ACL="public-read"` inside `upload_file()`'s `ExtraArgs` is **silently ignored** by QStorage. The file uploads successfully but remains private. Anonymous visitors get `403 Forbidden`.

**MUST:** Apply the public-read ACL as a **separate** `put_object_acl()` call **after** each upload completes.

```python
# Upload
s3.upload_file(filepath, BUCKET, key, ExtraArgs={"ContentType": content_type})

# Apply ACL as a SEPARATE call — mandatory on QStorage
s3.put_object_acl(Bucket=BUCKET, Key=key, ACL="public-read")
```

**NEVER:** Assume `ExtraArgs={"ACL": "public-read"}` works. It does not. It is silently discarded.

### GOTCHA 3: Path-Style URLs Require Authentication

```
❌  https://qstorage.quilibrium.com/BUCKET_NAME/index.html   (403 for anonymous)
✅  https://BUCKET_NAME.qstorage.quilibrium.com/index.html    (works publicly)
```

**MUST:** Use virtual-hosted-style URLs for any public-facing links or verification.

**NEVER:** Share or configure path-style URLs for public access.

### GOTCHA 4: Object-Level Cache-Control Is Silently Stripped

Setting `CacheControl` on uploads — via `upload_file()` `ExtraArgs={"CacheControl": ...}` or `put_object(CacheControl=...)` — is **silently discarded**. `head_object` returns `CacheControl: None` either way. No error, no warning. Same silent-no-op character as GOTCHA 2.

QStorage public URLs sit behind Cloudflare. Measured edge behavior: subresources (js/css/images) are cached with `max-age=14400` (4 hours); the HTML root document is served `DYNAMIC` — never edge-cached, so HTML edits are always instantly visible.

**MUST:** Cache-bust with version-string subresource references (e.g. `js/items.js?v=2`), bumped in the HTML whenever that asset's bytes change. The always-fresh HTML then points at URLs no edge has cached.

**NEVER:** Set `CacheControl` on uploads expecting it to take effect. It is silently stripped — do not set it at all.

**NEVER:** Assume a same-URL redeploy of a changed subresource is instantly visible. Returning visitors see the stale edge copy for up to 4 hours.

### GOTCHA 5: ErrorDocument Masquerade — Missing Keys Return 200

When `WebsiteConfiguration` sets an `ErrorDocument` (e.g. `index.html` for SPA routing), missing AND deleted keys return **HTTP 200 with the index.html body**. Status codes become useless for verification.

This is config-dependent: with `IndexDocument` only (no `ErrorDocument`), missing keys return a proper `404` XML error.

**MUST:** Inspect the response body **and** content-type when verifying (Steps 4 and 7) — never trust status codes alone.

**NEVER:** Treat a 200 as proof that a specific key exists when `ErrorDocument` is configured.

### GOTCHA 6: Dots in Bucket Names Break the Public URL

A dot in the bucket name turns the virtual-hosted URL into a **two-label subdomain** (e.g. `rosaryforhomeless.org.qstorage.quilibrium.com`). The `*.qstorage.quilibrium.com` wildcard TLS certificate covers single-label subdomains only, so the TLS handshake fails **before any HTTP** — OpenSSL reports `sslv3 alert handshake_failure`; browsers show `ERR_SSL_VERSION_OR_CIPHER_MISMATCH`. This is stricter than S3's general naming rules, which permit dots.

The bucket and objects themselves are fine — authenticated API access works — which makes this extra sneaky: uploads succeed, verification via API succeeds, only the public URL is dead. There is **no workaround**: path-style URLs 403 for anonymous visitors (GOTCHA 3), and QStorage has a single endpoint — no regional-endpoint trick like AWS S3 proper.

**MUST:** Use only lowercase letters, numbers, and hyphens in bucket names.

**NEVER:** Use dots in bucket names — the wildcard TLS cert cannot cover the resulting two-label subdomain, so the public URL fails TLS handshake before any HTTP (`ERR_SSL_VERSION_OR_CIPHER_MISMATCH`).

## Prerequisites

| Requirement | Details |
|---|---|
| Quilibrium account | Create at [console.quilibrium.com](https://console.quilibrium.com). Signup is manual — no automated account creation. |
| API credentials | Access Key ID, Secret Access Key, Account ID — generated from QConsole: **Services → QStorage → Generate API keys** |
| Python 3 with boto3 | `pip install boto3` |
| Static site files | A directory containing `index.html`, CSS, JS, and assets |

Account approval may require a push via support channels. If credentials are rejected immediately after creation, the account is likely still pending approval.

## Deployment Workflow

### Step 1: Create the boto3 Client

```python
import boto3

s3 = boto3.client(
    "s3",
    endpoint_url="https://qstorage.quilibrium.com",
    aws_access_key_id="YOUR_ACCESS_KEY_ID",
    aws_secret_access_key="YOUR_SECRET_ACCESS_KEY",
)
```

The `region` parameter has no effect on QStorage routing. `us-east-1` is a safe placeholder. Do not set it unless required by a downstream tool.

### Step 2: Create a Bucket

```python
BUCKET_NAME = "your-unique-bucket-name"

try:
    s3.create_bucket(Bucket=BUCKET_NAME)
    print(f"Bucket created: {BUCKET_NAME}")
except s3.exceptions.BucketAlreadyOwnedByYou:
    print(f"Bucket already exists: {BUCKET_NAME}")
except Exception as e:
    if "BucketAlreadyExists" in str(e):
        print(f"Bucket name taken: {BUCKET_NAME}. Choose another.")
    else:
        raise
```

Bucket names MUST be globally unique across all QStorage users, not just within one account.

> **Warning:** Bucket names MUST also be dot-free — only lowercase letters, numbers, and hyphens. A dot breaks the public URL entirely (GOTCHA 6).

### Step 3: Upload Files with ContentType and Separate ACL

This is the critical step. Upload each file, set its MIME type, then apply the public-read ACL as a **separate** call.

```python
import os
import mimetypes

SITE_DIR = "./your-site-directory"

for root, dirs, files in os.walk(SITE_DIR):
    for filename in files:
        filepath = os.path.join(root, filename)
        key = os.path.relpath(filepath, SITE_DIR)

        content_type, _ = mimetypes.guess_type(filename)
        if content_type is None:
            content_type = "application/octet-stream"

        # Upload with ContentType metadata
        s3.upload_file(
            filepath,
            BUCKET_NAME,
            key,
            ExtraArgs={"ContentType": content_type},
        )

        # Apply public-read ACL as a SEPARATE call (GOTCHA 2)
        s3.put_object_acl(
            Bucket=BUCKET_NAME,
            Key=key,
            ACL="public-read",
        )
        print(f"Uploaded + public ACL: {key} ({content_type})")
```

Omitting `ContentType` causes browsers to download files instead of rendering them.

### Step 4: Enable Static Website Hosting

```python
s3.put_bucket_website(
    Bucket=BUCKET_NAME,
    WebsiteConfiguration={
        "IndexDocument": {"Suffix": "index.html"},
        "ErrorDocument": {"Key": "index.html"},  # optional, for SPA routing
    },
)
print("Static website hosting enabled.")
```

> **Warning:** `ErrorDocument` is right for SPA client-side routing, but it makes missing and deleted keys return `200` + `index.html` (GOTCHA 5). Omit `ErrorDocument` for traditional multi-page sites to keep 404s meaningful.

### Step 5: Set Bucket Policy for Public Access

```python
import json

policy = {
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": f"arn:aws:s3:::{BUCKET_NAME}/*",
        }
    ],
}

s3.put_bucket_policy(Bucket=BUCKET_NAME, Policy=json.dumps(policy))
print("Public-read bucket policy applied.")
```

This complements but does **not** replace the per-object ACL from Step 3. Both are needed.

### Step 6: Add Social-Embed Metadata and Preview Card (Default)

Every deployment ships social-embed metadata (Open Graph + Twitter card) and a generated preview card image. This is **default behavior, not an optional extra** — link previews (iMessage, Slack, X/Twitter, Discord) render from these tags, and a site without them previews as a bare URL.

**MUST:** The deployed `index.html` `<head>` contains exactly one of each of the following:

```html
<meta property="og:title" content="{page title}">
<meta property="og:description" content="{meta description}">
<meta property="og:url" content="https://BUCKET_NAME.qstorage.quilibrium.com/">
<meta property="og:type" content="website">
<meta property="og:image" content="https://BUCKET_NAME.qstorage.quilibrium.com/og-card.png?v=1">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{page title}">
<meta name="twitter:description" content="{meta description}">
<meta name="twitter:image" content="https://BUCKET_NAME.qstorage.quilibrium.com/og-card.png?v=1">
```

- `og:title` — the page `<title>`. `og:description` — the existing `<meta name="description">` content. If the page has no description meta, write one and add it — a preview-quality fix, not scope creep.
- `og:url` — the bucket root, virtual-hosted style (GOTCHA 3).
- `og:image` / `twitter:image` — **absolute** URL to `og-card.png` in the same bucket, **with a version query (`?v=1`)**. QStorage strips object-level Cache-Control (GOTCHA 4), so `?v=N` is the only cache-bust: Cloudflare edge holds subresources ~4h while the HTML root is always DYNAMIC. The version query lets the image cache-bust independently of the HTML. Bump it (`?v=2`, ...) whenever `og-card.png` bytes change, updating `og:image` and `twitter:image` together.
- Full Twitter mirror: `twitter:card=summary_large_image` plus `twitter:title`, `twitter:description`, `twitter:image` mirroring the `og:` values.

**MUST:** Generate `og-card.png` — a 1200×630 PNG under ~250 KB, rendered via headless browser and styled from the site's own design tokens (palette and fonts pulled from its inline CSS), never generic clip-art. Save it into `SITE_DIR` so the Step 3 upload loop ships it (`.png` → `image/png`), with the separate public-read ACL (GOTCHA 2). Full recipe: [references/social-card.md](references/social-card.md).

**Editing an existing deployment (MUST):**

1. Fetch the **live** `index.html` with a cache-buster (`?cb=<timestamp>`) and edit those exact bytes — never re-deploy a stale local copy.
2. Touch **ONLY** the `<head>`: insert the og/twitter/description tags and bump version markers. **NEVER** modify body markup.
3. Preserve/bump in-file version markers per the existing versioning convention (GOTCHA 4) — bump `?v=N` only on assets whose bytes changed.
4. Keep the pre-edit bytes; the before/after diff is part of Step 7 verification.
5. Re-upload the edited HTML via `upload_file()` + separate `put_object_acl()` (GOTCHA 2), `ContentType: text/html`.

### Step 7: Verify the Deployment

**MUST:** Deep-verify — a 200 alone proves nothing (GOTCHA 5), and the edge may serve you a stale copy (GOTCHA 4).

1. Fetch via the virtual-hosted-style URL: `https://BUCKET_NAME.qstorage.quilibrium.com/`
2. Use a cache-busting query param (`?cb=<timestamp>`) on fetched subresources so you verify origin, not the Cloudflare edge.
3. Confirm **200 AND content-type AND body** — md5-match the body against the local file. Remember the fake-200 quirk (GOTCHA 5): a missing or deleted asset returns 200 with the `index.html` body — verify by body/content-type, never status alone.
4. Verify integrity with `etag == md5`: byte-compare remote etags from `list_objects_v2` against local md5s (`head_object` sometimes omits `ETag`; `list_objects_v2` always returns it).
5. Audit content-types: `head_object` every uploaded object, compare against the intended type, re-upload any mismatch. This catches MIME-guessing bugs that stamp wrong types (e.g. every file becoming `text/javascript`).
6. Audit the key set: `list_objects_v2` shows the new `og-card.png` key and **no dangling keys** — the remote key set must match `SITE_DIR` exactly (stragglers mean a stale or half-updated deployment).
7. Cache-busted GET of **both** objects: `index.html` (200 + byte-identical body + `text/html`) and `og-card.png` at the **exact scraper-facing URL including its version query** — e.g. `https://BUCKET_NAME.qstorage.quilibrium.com/og-card.png?v=1` (200 + byte-identical body + `image/png`). That exact URL is what social platforms request; a 200 on the unversioned path proves nothing about it.
8. Grep the **deployed bytes** (the fetched response, not local files) for `og:` / `twitter:` tags; parse-check for duplicates and malformed tags — each property exactly once, well-formed quoted content.
9. Sanity-check the fetched `og-card.png`: valid PNG signature (`\x89PNG\r\n\x1a\n`) and dimensions exactly 1200×630.
10. Before/after diff of the HTML: proves **only** head additions + version-marker bumps changed. Any body diff is a bug — fix and redeploy.

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| `AccessDenied` on uploaded files | ACL not applied separately (GOTCHA 2) | Run `put_object_acl()` on each object as a separate call |
| `AccessDenied` from AWS CLI upload | AWS CLI v2 protocol issue (GOTCHA 1) | Switch to boto3 for all upload operations |
| `AccountSuspended` on any request | Account pending approval | Wait and retry. Contact Quilibrium support if it persists beyond 24 hours. |
| `BucketAlreadyExists` | Bucket name taken | Choose a different globally unique bucket name |
| Files download instead of rendering | Missing `ContentType` metadata | Re-upload with `ExtraArgs={"ContentType": "text/html"}` or the correct MIME type |
| `403` on public URL | Using path-style URL (GOTCHA 3) | Switch to virtual-hosted style: `https://BUCKET_NAME.qstorage.quilibrium.com/` |
| `403` on public URL with correct style | Bucket policy not applied or ACL missing | Apply both bucket policy (Step 5) and per-object ACL (Step 3) |
| Changes live at origin but visitors see old version for up to 4h | Cloudflare edge caches subresources (`max-age=14400`); `CacheControl` is stripped (GOTCHA 4) | Bump version-string refs (`?v=N`) on every asset whose bytes changed |
| Link preview shows no image, or a stale image after a card update | `og-card.png` missing or wrong content-type, or the edge still serves the old card under the same `?v=N` (GOTCHA 4) | Verify the exact `og:image` URL returns 200 + `image/png` body (Step 7); bump `?v=N` on `og:image` and `twitter:image` together |
| Transient `403` on `head_object` right after upload | Propagation delay — harmless | Retry after a few seconds; do not treat as failure |
| Deleted or nonexistent key returns `200` | `ErrorDocument` masquerade (GOTCHA 5) | Verify by inspecting body + content-type, not status code |
| TLS handshake failure / `ERR_SSL_VERSION_OR_CIPHER_MISMATCH` on the bucket's public URL | Dot in bucket name creates a two-label subdomain the wildcard cert doesn't cover (GOTCHA 6) | Recreate under a dot-free name (lowercase letters, numbers, hyphens) and redeploy |

## References

- **Full deploy script**: See [references/deploy-script.md](references/deploy-script.md) for a copy-paste-ready `deploy.py` with all six configuration variables at the top.
- **Social preview card recipe**: See [references/social-card.md](references/social-card.md) for the `og-card.png` generation recipe — design-token extraction, headless-browser render, and pre-upload validation (Step 6).
- **Configuration reference**: See [references/configuration.md](references/configuration.md) for endpoint, region, credential format, and URL pattern reference.
