xhost
Sign in

Blob recipe: uploads on the channel's object store

What you get

A FastAPI service stores uploaded files in the S3-compatible object store. xhost provisions that store for the channel. POST /files uploads one file. GET /files lists the files. GET /files/{key} downloads one file. DELETE /files/{key} removes one file. You create no bucket, you configure no credentials and you set no flag. The platform injects five S3_* variables into the container, and boto3 uses them unchanged.

The two reads are public. The two writes need Authorization: Bearer <token>. The app compares that token with a WRITE_TOKEN that you set yourself with set_env(app_id, key="WRITE_TOKEN", value=..., secret=True).

The split between the reads and the writes is necessary. An app on a public hostname that accepts files from anyone is an open object store. That store holds the bytes that your plan's quota counts. If you do not set WRITE_TOKEN, the app refuses every write. Thus a forgotten secret gives you a locked app, never a public one.

The example app is live at recipe-blob-docs.xhostd.com. The public can only read it. The GET calls in this guide reach the app. The app refuses the POST and DELETE calls, because this guide does not publish its write token. If you do the recipe on your own account, you get the same service under your own name, and you hold the only token.

The app runs on the app template. Thus install.sh and launch.sh obey the contract in Recipe: Python API on the app template. This guide adds only the storage layer.

The five injected variables

Every channel has its own object store. The platform provisions that store when it creates the channel. When the store becomes ready, each deploy of the channel injects these five variables into the container. Before the store is ready, no deploy injects them. A store that is not yet ready does not stop a deploy: the deploy continues, but the variables are absent.

Variable What it holds
S3_ENDPOINT This cell's own S3 gateway, an http://<overlay-ip>:6533 URL on the private WireGuard overlay
S3_BUCKET The channel's virtual bucket name — the first label of its hostname, here recipe-blob-docs
S3_ACCESS_KEY_ID The channel's own scoped key id
S3_SECRET_ACCESS_KEY Its secret
S3_REGION us-east-1, a neutral placeholder

Read these five facts before you write code.

Stock SigV4 works. You do not need a botocore Config. The endpoint is your own cell's gateway on a private overlay network. Thus the URL is plain http, and you configure nothing about TLS, the addressing_style or the signature version. Pass endpoint_url, the key pair and the region to boto3.client("s3", ...). Pass nothing more. A Config(...) is the most common unnecessary edit here. A wrong Config(...) — an old signature_version, or a forced addressing_style — makes a correct client fail with a signature error.

Only code inside the container can reach that endpoint. It is an overlay address. Nothing on your laptop can route to it.

Pass S3_BUCKET exactly as the platform gives it to you. The gateway compares the bucket name in every request with the channel's own name. Any other name gets a 404 NoSuchBucket, on a read, a write and a copy alike. Read the value from the environment. Do not hardcode it, and do not reuse another channel's name.

S3_REGION is a placeholder. The real upstream region never appears in your container. Pass the value, because the SDK needs one. Give it no other meaning.

Keys are plain and have no prefix. Upstream, the gateway puts every object under this channel's own private key prefix. The key notes.txt in your code is notes.txt in every list. It cannot collide with another channel's object of the same name, and it cannot reach one. The gateway refuses access between channels; this is not only a convention. Keep out of one name space: .xhost/. The platform keeps .xhost/ for its own deploy snapshot markers. The gateway refuses a key under .xhost/ with a 403, and removes the markers from your lists.

You enable nothing. The app record shows a flag named external_blob_access_enabled. That flag is not part of this recipe. It controls whether S3 tools outside the platform can reach the store through the public gateway. Examples are aws s3 on your laptop, rclone, and an SDK on another host. The flag is a protected action, and it is false for the app in this recipe. A person sets it in the console; the platform answers a 403 protected_action error to an agent. Your container's own access does not depend on it.

The files

Four files at the repo root.

app.py

import hmac
import logging
import os

import boto3
from botocore.exceptions import ClientError
from fastapi import Depends, FastAPI, Header, HTTPException, UploadFile
from fastapi.responses import Response

# uvicorn's logger, so these lines show up in the channel's logs.
logger = logging.getLogger("uvicorn.error")

BUCKET = os.environ["S3_BUCKET"]

# Your own secret, set with set_env. Reads stay public; writes carry it.
WRITE_TOKEN = os.environ.get("WRITE_TOKEN", "")

# The platform injects all five S3_* variables once the channel's blob store
# is ready. S3_ENDPOINT is this cell's own gateway on the private overlay
# network, so plain http and stock SigV4 are correct here — no botocore
# Config, no TLS options.
s3 = boto3.client(
    "s3",
    endpoint_url=os.environ["S3_ENDPOINT"],
    aws_access_key_id=os.environ["S3_ACCESS_KEY_ID"],
    aws_secret_access_key=os.environ["S3_SECRET_ACCESS_KEY"],
    region_name=os.environ["S3_REGION"],
)

app = FastAPI()

# Object names arrive from upload filenames and URL paths, so they are
# untrusted. The gateway rejects traversal too, but a 400 here is a clearer
# answer than its 403.
_BAD_SEGMENTS = frozenset({"", ".", ".."})


def _checked_key(key: str) -> str:
    if not key or any(segment in _BAD_SEGMENTS for segment in key.split("/")):
        raise HTTPException(status_code=400, detail="Invalid object name.")
    return key


def require_write_token(authorization: str = Header(default="")) -> None:
    # No token configured means no writes at all. Failing closed keeps a
    # forgotten WRITE_TOKEN from leaving the store open to anyone.
    if not WRITE_TOKEN:
        raise HTTPException(
            status_code=503, detail="Writes are not configured."
        )
    scheme, _, presented = authorization.partition(" ")
    # compare_digest, not ==: a wrong token must not be findable by timing.
    if scheme != "Bearer" or not hmac.compare_digest(
        presented.encode(), WRITE_TOKEN.encode()
    ):
        raise HTTPException(status_code=401, detail="Invalid write token.")


# boto3 blocks, so every route below is a sync `def` — FastAPI runs those in a
# threadpool instead of on the event loop.


# The health check probes GET / and needs a 2xx.
@app.get("/")
def root():
    return {"ok": True, "service": "recipe-blob-uploads", "bucket": BUCKET}


# Keys are plain and unprefixed: the gateway files every object under this
# channel's own private prefix, so "notes.txt" here can never collide with or
# reach another channel's objects. The one name to avoid is anything under
# ".xhost/", which is reserved for the platform.
@app.post("/files", dependencies=[Depends(require_write_token)])
def upload(file: UploadFile):
    key = _checked_key(file.filename or "")
    try:
        s3.upload_fileobj(file.file, BUCKET, key)
    except ClientError as exc:
        logger.error("upload of %s failed: %s", key, exc)
        raise HTTPException(
            status_code=502, detail="Could not store the file."
        ) from exc
    return {"key": key, "bytes": file.size}


@app.get("/files")
def list_files():
    try:
        # One page, up to 1000 objects. Use s3.get_paginator("list_objects_v2")
        # once you expect more than that.
        page = s3.list_objects_v2(Bucket=BUCKET)
    except ClientError as exc:
        logger.error("listing failed: %s", exc)
        raise HTTPException(
            status_code=502, detail="Could not list the files."
        ) from exc
    return {
        "files": [
            {
                "key": obj["Key"],
                "bytes": obj["Size"],
                "modified": obj["LastModified"].isoformat(),
            }
            for obj in page.get("Contents", [])
        ]
    }


@app.get("/files/{key:path}")
def download(key: str):
    checked = _checked_key(key)
    try:
        obj = s3.get_object(Bucket=BUCKET, Key=checked)
    except ClientError as exc:
        # A missing object can come back as AccessDenied instead of NoSuchKey:
        # the store will not confirm a key is absent to a caller scoped to one
        # prefix. Every key this app can name is inside its own channel's
        # prefix, so both codes mean the same thing here — no such file.
        if exc.response["Error"]["Code"] in ("NoSuchKey", "AccessDenied"):
            raise HTTPException(
                status_code=404, detail="No such file."
            ) from exc
        logger.error("download of %s failed: %s", checked, exc)
        raise HTTPException(
            status_code=502, detail="Could not read the file."
        ) from exc
    # Objects come back untyped: the gateway does not carry the upload's
    # Content-Type upstream. Keep your own metadata (a row in Postgres, a
    # naming convention) if a stored type matters to you.
    return Response(
        content=obj["Body"].read(), media_type="application/octet-stream"
    )


@app.delete("/files/{key:path}", dependencies=[Depends(require_write_token)])
def delete(key: str):
    checked = _checked_key(key)
    try:
        s3.delete_object(Bucket=BUCKET, Key=checked)
    except ClientError as exc:
        logger.error("delete of %s failed: %s", checked, exc)
        raise HTTPException(
            status_code=502, detail="Could not delete the file."
        ) from exc
    return {"deleted": checked}

Five details in that file are important.

BUCKET = os.environ["S3_BUCKET"] runs at import. This is a deliberate trade-off, and it has a good result and a bad result. A channel whose blob store is not ready has no S3_* variable in its environment. Then this line raises KeyError, and the process does not start. The deploy fails on the health check. That result is better than an app that answers / with a 200 and fails every POST /files with a 500. But the deploy still fails. If you want a channel that starts without storage, read the variable in the handler, and answer 503 when the variable is absent.

Every route is a sync def, not async def. boto3 blocks the thread. A call that blocks in an async def handler stops the whole event loop. Then one slow upload stops every other request in the process. FastAPI runs a plain def handler in a threadpool, which is correct for an SDK that blocks.

_checked_key rejects the name before the SDK sees it. An object name comes from the filename of an upload, or from the URL path. An attacker controls both. The gateway also rejects a path traversal, so this check is a second defence, not the only one. But a 400 with Invalid object name. is a better answer than a 502 that hides the gateway's refusal.

The AccessDenied branch in download. This branch is unexpected. A section below explains it.

A download has the type application/octet-stream. The gateway does not send your upload's Content-Type upstream. Thus an object comes back with no type, whatever the browser sent. If the stored type is important to you — for example, you show the images in an <img> tag — keep the type yourself. Put it in a Postgres row, or encode it in the key name. Then set the response type from your own record.

requirements.txt

fastapi==0.115.6
uvicorn==0.34.0
python-multipart==0.0.20
boto3==1.43.59
botocore==1.43.59

FastAPI needs python-multipart to parse a multipart/form-data body. Without it, an UploadFile parameter fails at request time, not at import time. Thus the app starts, and then answers 500 on the first upload. This recipe pins boto3 and botocore to the same version on purpose. boto3 accepts only a narrow range of botocore versions. If you pin one of the two only, the other one can change.

install.sh

#!/bin/sh
# Runs at BUILD time, as root. --system installs into the image's own
# interpreter, so launch.sh needs no virtualenv activation.
set -eu

uv pip install --system --no-cache -r requirements.txt

launch.sh

#!/bin/sh
# Runs at BOOT, as the non-root 'app' user. Never install anything here.
set -eu

exec uvicorn app:app --host 0.0.0.0 --port "$XHOST_HTTP_PORT"

The deploy

Every app owns a git repo. git push, then deploy, is the standard path. A push sends only the diff. Thus each edit after the first one costs a few lines, not the full contents of every file through a tool call. Use commit_files in one situation only: git is not available on the machine that you work on. A push stores your code; it does not deploy the code. deploy is a separate call, so you name the commit that goes live.

There are five steps: the four steps that every app takes, and the write token. If this is your first app, the static site recipe explains the four common steps in more detail.

1. Create the app. This call provisions the prod channel, its hostname, its git repo and its object store.

create_app(name="recipe-blob", template="app")
→ {"id": "efb0f79a-ab98-4bb2-ad8a-40b27ab3f7fc",
   "name": "recipe-blob",
   "template": "app",
   "repo_url": "https://git.xhostd.com/docs/recipe-blob.git",
   "channels": [{"id": "21eb795a-7071-407b-8cf0-0e4f940af3c8",
                 "name": "prod",
                 "hostname": "recipe-blob-docs.xhostd.com",
                 "current_sha": null}], ...}

Keep the app id and the prod channel id. Every later call takes one or both.

2. Mint a credential. The token is your git password, and it is valid for 30 days.

get_credentials()
→ {"token": "xh_...", "username": "docs",
   "expires_at": "2026-08-30T17:26:27Z",
   "scopes": ["blob:*", "deploy:*", "repo:*", "db:*", "channel:*"]}

Put it in the password field of the remote URL: https://<username>:<token>@git.xhostd.com/<username>/<app>.git.

3. Clone, commit, push. A new xhost repo is empty, and git tells you so. The warning is correct; it is not a fault:

$ git clone https://docs:$XHOST_TOKEN@git.xhostd.com/docs/recipe-blob.git
Cloning into 'recipe-blob'...
warning: You appear to have cloned an empty repository.

Write the four files into that directory. Then commit and push them:

$ cd recipe-blob
$ git add -A
$ git commit -m "blob-uploads recipe"
$ git push origin master

Do not put the token in a file that you commit, or in text that you paste. $XHOST_TOKEN above holds the value from get_credentials. A remote URL with a real token in it stays in .git/config, in your shell history and in the output of every git remote -v.

4. Set the write token. The two write routes check this value.

set_env(app_id="efb0f79a-ab98-4bb2-ad8a-40b27ab3f7fc",
        key="WRITE_TOKEN",
        value="<a long random value of your own — redacted here>",
        secret=True)

secret=True is important. MCP never gives a secret value back: list_env returns the key, its kind, its scope and its change time, but a null value. To read the value, use the click-to-reveal control in the web console, or the HTTP API call GET /apps/{app_id}/env/{key}/value. The platform audits every reveal. A plain variable is the default, and list_env returns it as cleartext. Cleartext is the wrong place for a credential that permits writes to your object store.

The order of step 4 and step 5 is important. The platform injects the environment into a container when the container starts. Thus the token must exist before the deploy that needs it. If you set the token first, the container from step 5 already holds it. If you set the token after step 5, the app refuses every write until you deploy again.

5. Deploy the branch.

deploy(app_id="efb0f79a-ab98-4bb2-ad8a-40b27ab3f7fc",
       channel_id="21eb795a-7071-407b-8cf0-0e4f940af3c8",
       ref="master")
→ {"deploy_id": "f81e4372-9891-429d-bf43-ebc8d9259a10",
   "channel_id": "21eb795a-7071-407b-8cf0-0e4f940af3c8",
   "status": "queued"}

ref is a branch name. xhostd resolves it to the current head of that branch, so after a push you do not need the sha. deploy also accepts sha when you want an exact commit, and sha has priority if you pass both. The deploy then runs in the background. Use get_deploy_log to follow it.

Verify it

The deploy log

This log comes from the deploy above, f81e4372-9891-429d-bf43-ebc8d9259a10. It does not include the buildkit lines that teach nothing. Every deploy of the channel writes the same set of lines.

...
[2026-07-31T22:00:19+00:00] [build] #7 0.482 Resolved 20 packages in 313ms
[2026-07-31T22:00:21+00:00] [build] #7 1.738 Installed 20 packages in 73ms
[2026-07-31T22:00:21+00:00] [build] #7 1.738  + boto3==1.43.59
[2026-07-31T22:00:21+00:00] [build] #7 1.738  + botocore==1.43.59
[2026-07-31T22:00:21+00:00] [build] #7 1.738  + python-multipart==0.0.20
...
[2026-07-31T22:00:30+00:00] [build] image 1007.98 MB total, 59.33 MB charged — base xhost-runtime:node22-py313 exempt
[2026-07-31T22:00:32+00:00] channel snapshot saved: 0.00 MB
[2026-07-31T22:00:33+00:00] blob snapshot saved: ts=2026-07-31T22:00:32.447000+00:00
[2026-07-31T22:00:33+00:00] blob record pushed to cell gateway
[2026-07-31T22:00:33+00:00] health_check container=ac9d93ec09c193f560674fdd7a0dec2eb4adadce473cdc4921ed824dbd67a8ef port=3000 timeout=120.0s
[2026-07-31T22:00:33+00:00] [container] [xhost] starting launch.sh (XHOST_HTTP_PORT=3000) ...
[2026-07-31T22:00:36+00:00] [container] INFO:     Uvicorn running on http://0.0.0.0:3000 (Press CTRL+C to quit)
[2026-07-31T22:00:37+00:00] health_check ok
[2026-07-31T22:00:37+00:00] [container] INFO:     10.77.1.5:56020 - "GET / HTTP/1.1" 200 OK
[2026-07-31T22:00:37+00:00] caddy ensure_route hostname=recipe-blob-docs.xhostd.com upstream=10.77.1.5:32044
[2026-07-31T22:00:38+00:00] deploy success

Two of those lines are specific to a channel with a blob store.

blob snapshot saved: ts=.... Before your new container replaces the old container, the platform marks a moment in the channel's object store. It does the same for the channel's Postgres schema. The platform writes one marker object, not a copy. The store keeps the full version history, so a timestamp is enough to return every object to its state at that moment. You do not request the marker, and you cannot omit it.

blob record pushed to cell gateway. This line explains why your key pair works. The platform gives the channel and its scoped upstream credential to the cell-local gateway. It does this before the container that uses them starts. If a deploy reaches health_check without this line, check the S3 calls from the container first.

The line above them is image 1007.98 MB total, 59.33 MB charged. It is the standard image-size report: only the layers that you add on the warm xhost-runtime base count against your plan's cap.

The curl

The transcript below shows the full sequence against the live channel. It shows the public reads, the two conditions that refuse a write, and the two writes that carry the token. $WRITE_TOKEN holds the value from step 4. The token itself never appears in a transcript.

$ curl -sS https://recipe-blob-docs.xhostd.com/
{"ok":true,"service":"recipe-blob-uploads","bucket":"recipe-blob-docs"}

$ printf 'the quick brown fox\n' > notes.txt

$ curl -sS -w ' [%{http_code}]\n' -F 'file=@notes.txt' \
    https://recipe-blob-docs.xhostd.com/files
{"detail":"Invalid write token."} [401]

$ curl -sS -w ' [%{http_code}]\n' -F 'file=@notes.txt' \
    -H 'Authorization: Bearer not-the-token' \
    https://recipe-blob-docs.xhostd.com/files
{"detail":"Invalid write token."} [401]

$ curl -sS -F 'file=@notes.txt' \
    -H "Authorization: Bearer $WRITE_TOKEN" \
    https://recipe-blob-docs.xhostd.com/files
{"key":"notes.txt","bytes":20}

$ curl -sS https://recipe-blob-docs.xhostd.com/files
{"files":[{"key":"notes.txt","bytes":20,"modified":"2026-07-31T22:05:40.166000+00:00"}]}

$ curl -sS https://recipe-blob-docs.xhostd.com/files/notes.txt
the quick brown fox

$ curl -sS -w ' [%{http_code}]\n' -X DELETE \
    https://recipe-blob-docs.xhostd.com/files/notes.txt
{"detail":"Invalid write token."} [401]

$ curl -sS -X DELETE -H "Authorization: Bearer $WRITE_TOKEN" \
    https://recipe-blob-docs.xhostd.com/files/notes.txt
{"deleted":"notes.txt"}

$ curl -sS https://recipe-blob-docs.xhostd.com/files
{"files":[]}

$ curl -sS -w ' [%{http_code}]\n' https://recipe-blob-docs.xhostd.com/files/nope.txt
{"detail":"No such file."} [404]

$ curl -sS -w ' [%{http_code}]\n' --path-as-is 'https://recipe-blob-docs.xhostd.com/files/../etc/passwd'
{"detail":"Invalid object name."} [400]

That hostname is the live demo, so the GET calls above reach a real app. The POST and DELETE calls always answer 401 there, because the demo's write token is not public. To see the successful writes, you must deploy your own app and use your own token. The last list does not reproduce either. After the capture of this transcript, an upload put notes.txt back, so the file that the delete removed is present again.

Read four points in that transcript.

The reads carry no credential, and the app refuses a write in two conditions. GET /, GET /files and GET /files/{key} answer every caller. POST /files and DELETE /files/{key} answer 401 when the request has no Authorization header. They answer 401 again when the header holds a wrong token. The body is the same in both conditions, because a different body tells an anonymous caller which part of the request was correct. GET / is public for a second reason: the deploy's health check probes that route.

The key in that list is notes.txt, with no prefix in front of it. The channel prefix exists upstream, and the gateway applies it on every call. The prefix is never part of the name that your code handles.

The app checks the last two calls itself; the store does not. A key that is absent gives a 404. _checked_key refuses a name that points outside the prefix, and answers 400 before the app calls boto3.

The transcript omits one path on purpose: the 503. To capture that path, the app must run without a WRITE_TOKEN. This channel is a public demo, and a deploy without a write token puts the demo in the exact state that the token prevents. Thus this guide describes the behaviour, and does not capture it. Without a token in the container, POST /files and DELETE /files/{key} answer 503 with {"detail":"Writes are not configured."}. They give that answer to every caller, with a correct header or without one. The reads continue to work. The list of failure modes below tells you what to do.

Object versions, deletes and your usage number

Every channel's store keeps all versions of an object. This behaviour is S3 object versioning, and you cannot turn it off. Two results follow, and both surprise people.

A second write to a key does not replace the object. A PUT to a key that exists adds a new current version. It makes the previous version noncurrent. The old bytes stay in the store.

A delete does not erase anything either. The store writes a delete marker, and that marker becomes the new head of the key. The key is not in a list any more, and a GET answers as if the object is absent. But the versions below the marker stay in the store.

This history is deliberate, and it makes the blob snapshot saved line above useful. A snapshot is a timestamp. A restore finds the version of each key that was current at that moment, and writes that version again as the new head. A restore only adds versions. Thus a restore cannot destroy another snapshot, and you can undo a restore.

The usage number in get_blob_usage and on the channel stats page counts live bytes only. Live bytes are the latest version of each key that has no delete marker. A noncurrent version and a key with a delete marker do not count against your plan's quota. Thus a delete does free space, although the store erases nothing. The gateway does not compute the usage on the request path, so the new number appears about one minute later. After a large delete, wait about one minute before you decide that the quota is wrong.

When it goes wrong

A missing object comes back as AccessDenied, not NoSuchKey

This behaviour is unexpected, and it can cost you hours of work. The example handles NoSuchKey and AccessDenied in the same branch. Both codes are necessary there. If your code handles NoSuchKey only, a request for a key that does not exist — GET /files/nope.txt — misses that branch. The request then goes to the generic branch, and the app answers 502 instead of 404.

The cause is the isolation guarantee, and the guarantee is correct. Your channel's upstream credential has a scope: your channel's own key prefix. The storage provider does not confirm to such a caller that an object is absent. The answer "no such key" is itself information about a name space that the caller has no permission to see. Thus the provider answers AccessDenied. The gateway sends the upstream status and body on without a change, so boto3 receives AccessDenied.

Your app has no real ambiguity. Every key that your code can name is inside your own channel's prefix. Thus an AccessDenied on a GET has one meaning only: there is no such object. Treat both codes as 404. The download route does this:

if exc.response["Error"]["Code"] in ("NoSuchKey", "AccessDenied"):

Note what that branch does not do: it raises the 404 and does not call logger.error. A request for a file that is absent is an ordinary answer, not a fault. Thus the correct behaviour writes nothing: a 404, and no line in the runtime log. If you remove AccessDenied from that tuple, the same request goes to the branch below. That branch writes the botocore error to the log and answers 502. A 502 where you expect a 404 is the symptom to look for. Best practices explains how to read the runtime log.

An upload returns 507

A 507 Insufficient Storage from the gateway means that the PUT goes past your plan's blob quota. The quota is per user. It is the sum over every channel of every app that you own. Thus one channel can cause a 507 on another channel.

Delete the objects that you do not need. Then send the PUT again. The new space appears about one minute later, because the gateway computes the usage off the request path. Thus the first new PUT can fail, and a later one succeeds.

Look at how your own code reports the 507. The single except ClientError in this example changes a 507 into the app's generic 502 Could not store the file. That answer is true, but it does not help the person who uploads the file. To tell the user that there is no more space, test the status code:

if exc.response["ResponseMetadata"]["HTTPStatusCode"] == 507:
    ...

KeyError: 'S3_BUCKET' at startup

The channel's blob store was not ready at the time of the deploy. Thus the platform injected no S3_* variable, and os.environ["S3_BUCKET"] at module level raised the error. The container exits immediately. The health check finds nothing on the port. The deploy then fails on a timeout, and the runtime log holds the traceback.

The platform provisions a store in a very short time, and it sweeps again for a store that stays in provisioning. Thus the usual repair is a new deploy. If you prefer an app that starts without storage, read the variables in the handlers, not at module scope.

Every write answers 503 Writes are not configured.

The container has no WRITE_TOKEN, or it has an empty one. The check fails closed, so the app refuses every upload and every delete. Without a token to compare, the only safe answer is a refusal. The reads still work. Thus the deploy passed, and the first upload showed you the fault.

Set the value with set_env(app_id, key="WRITE_TOKEN", value=..., secret=True). Then deploy the channel again. A change to the environment reaches the container at the next deploy only. Thus a change against a live channel does nothing until you deploy that channel again.

A write answers 401 Invalid write token.

The app has a write token, and the request does not match it. Three conditions give this answer. First, the request has no Authorization header. Second, the header scheme is not Bearer; a common error is a bare token as Authorization: <token>. Third, the token does not match the configured one. The answer is the same for all three on purpose, because a different answer tells an anonymous caller which part of the request was correct.

The 503 above and this 401 are different codes on purpose. The 503 means that the app has no write token. The 401 means that your request has no correct credential. If you are sure that you sent the correct value, confirm the value itself. list_env never returns a secret. Thus you cannot recover a lost token; you set a new one with another set_env.

You added a botocore Config

The symptoms are signature errors: SignatureDoesNotMatch, or a 400 from a request that looks correct. They occur on a client that worked before your edit, or on the first call of a client that you copied from an AWS example. The gateway uses stock SigV4 on the injected endpoint, and boto3's defaults are correct for it. Delete the Config. Pass only endpoint_url, the key pair and region_name.

You want an external-access toggle

external_blob_access_enabled is not the control that makes your app's storage work. A change to it repairs no fault inside the container. It controls whether the public S3 gateway gives your channel's credentials to a client outside the platform. It is a protected action: a person sets it in the console, and the platform answers a 403 protected_action error to an agent. It changes nothing on the injected S3_ENDPOINT path that your container uses. If the S3 calls in your app fail, the cause is elsewhere in this list.

Files come back with the wrong type

The store keeps an object with no type. The gateway does not send the upload's Content-Type upstream. Thus a GET has no type to return, and the example answers application/octet-stream. A browser that receives that type downloads the file; it does not show it. Keep the type yourself: a column next to the key in Postgres, or a file extension that you map. Then set the media_type of the response from your own record.

You wrote a key under .xhost/

The platform keeps that sub-prefix for its deploy snapshot markers. The gateway refuses any key under it with a 403, on a write and on a read. The gateway also removes its own markers from your lists. Thus you never see the platform's objects, and you cannot put your own objects beside them. boto3 raises the refusal as a ClientError, and this example answers it with the generic 502. Use another name.