qconsole-deploy-static-site · git:20260827.96ce18e · 2026-08-27 · sha256 d3a2b0603a38de2f
qconsole-deploy-static-site git:20260827.96ce18eA
Immutable. This exact content never changes and is served at /api/v1/blob/d3a2b0603a38de2f.
---
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. Five protocol-level incompatibilities and service-behavior traps ca
use silent failures that standard S3 workflows do not produce. Every instruction below accounts for them.
## Critical Gotchas
These five 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 fi
ve 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 `40
3 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 `Cac
heControl: 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 `DYNAM
IC` — 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.
## 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.
### 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 traditi
onal 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-st
ring 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 |
## 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.