qconsole-deploy-static-site · git:20260904.43ddd1b · 2026-09-04 · sha256 91ebfc804ae38679

qconsole-deploy-static-site git:20260904.43ddd1bA

Immutable. This exact content never changes and is served at /api/v1/blob/91ebfc804ae38679.

---
name: qconsole-deploy-static-site
description: Use when deploying static sites (HTML, CSS, JS) to Quilibrium QStorage, configuring S3-compatible buckets with boto3, 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 6) — 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: 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. Never trust 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`).

## 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 |
| 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.
- **Configuration reference**: See [references/configuration.md](references/configuration.md) for endpoint, region, credential format, and URL pattern reference.