on this page

g7S3 API

S3 API

g7 buckets speak the S3 API. Point any S3 client at https://g7.tissue.systems with a bucket's credentials and it can list, read, write, and delete objects. The AWS CLI, boto3, rclone, and the S3 SDK for your language all work without a plugin or a compatibility layer.

This is the access path for everything outside a Cell: CI jobs pushing build artifacts, a laptop pulling backups, a media pipeline that already speaks S3. Inside a Cell, use the g7 binding instead. It's faster and needs no credentials.


Endpoint

Endpoint URL https://g7.tissue.systems
Region tissue
Addressing Path-style (https://g7.tissue.systems/<bucket>/<key>)
Authentication AWS Signature Version 4, in request headers or presigned query strings

Every client needs the endpoint URL and the region set explicitly. Most also need path-style addressing; the AWS CLI and boto3 pick it up on their own once endpoint_url is set, and the examples below show the setting for clients that do not.


Get credentials for a bucket

Each bucket has its own access key. A key unlocks exactly one bucket. It can't see or touch anything else in your account, so it's safe to hand a key to a single CI job or a single machine.

From the dashboard: open the bucket, then Show credentials.

From the API, with a token carrying the buckets:write scope:

curl -X POST https://api.tissue.systems/v1/buckets/avatars/s3-credentials \
  -H "Authorization: Bearer $TISSUE_TOKEN"
{
  "bucket": "avatars",
  "accessKeyId": "GKa1b2c3d4e5f60718293a4b5c",
  "secretAccessKey": "…",
  "region": "tissue",
  "endpoint": "https://g7.tissue.systems"
}

The call returns the bucket's existing key rather than minting a new one, so it is safe to run twice. Treat the secret like a password: it can write and delete every object in the bucket. Rotating a key isn't self-serve yet, so contact support if one leaks.


Install the AWS CLI

The examples use AWS CLI v2. Version 1 is unmaintained; if aws --version reports aws-cli/1.x, upgrade.

macOS

curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /

Or with Homebrew:

brew install awscli

Linux (x86_64)

curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install

On ARM machines (a Raspberry Pi, an Ampere or Graviton VM), substitute awscli-exe-linux-aarch64.zip.

Windows

msiexec.exe /i https://awscliv2.amazonaws.com/AWSCLIV2.msi

Confirm the install:

aws --version
aws-cli/2.35.7 Python/3.14.6 Darwin/25.5.0 source/arm64

Configure the AWS CLI

Environment variables suit CI jobs and throwaway shells:

export AWS_ACCESS_KEY_ID=GKa1b2c3d4e5f60718293a4b5c
export AWS_SECRET_ACCESS_KEY=…
export AWS_DEFAULT_REGION=tissue
export AWS_ENDPOINT_URL=https://g7.tissue.systems

aws s3 ls s3://avatars/

A named profile suits a workstation you use every day. In ~/.aws/config:

[profile tissue]
region = tissue
endpoint_url = https://g7.tissue.systems

In ~/.aws/credentials:

[tissue]
aws_access_key_id = GKa1b2c3d4e5f60718293a4b5c
aws_secret_access_key = …

Then pass --profile tissue to any command:

aws --profile tissue s3 ls s3://avatars/

A profile per bucket keeps the one-key-one-bucket boundary visible in the command you type. If you would rather not configure anything, --endpoint-url works on every invocation:

aws --endpoint-url https://g7.tissue.systems s3 ls s3://avatars/

AWS CLI examples

The commands below assume AWS_ENDPOINT_URL and credentials are already in the environment.

List objects

aws s3 ls s3://avatars/
aws s3 ls s3://avatars/portraits/ --recursive

Upload and download

aws s3 cp ./photo.jpg s3://avatars/portraits/alice.jpg
aws s3 cp s3://avatars/portraits/alice.jpg ./alice.jpg

Objects larger than 8 MB are split into parts and uploaded in parallel automatically, so a multi-gigabyte file is one cp like any other.

Set the content type so browsers render the object instead of downloading it:

aws s3 cp ./index.html s3://site-assets/index.html \
  --content-type text/html \
  --cache-control "public, max-age=3600"

Sync a directory, transferring only what changed:

aws s3 sync ./dist s3://site-assets/ --delete
aws s3 sync s3://site-assets/ ./restore

Move, copy, and delete

aws s3 mv s3://avatars/old.jpg s3://avatars/archive/old.jpg
aws s3 rm s3://avatars/portraits/alice.jpg
aws s3 rm s3://avatars/thumbnails/ --recursive

Presign a URL so someone without credentials can fetch one object:

aws s3 presign s3://avatars/portraits/alice.jpg --expires-in 3600

Lower-level calls through s3api when you need the raw response:

aws s3api head-object --bucket avatars --key portraits/alice.jpg
aws s3api list-objects-v2 --bucket avatars --prefix portraits/ --max-keys 100
aws s3api get-object --bucket avatars --key video.mp4 --range 'bytes=0-1048575' first-mb.bin

Python

Install boto3:

pip install boto3

Build a client once and reuse it:

import boto3
from botocore.client import Config

s3 = boto3.client(
    "s3",
    endpoint_url="https://g7.tissue.systems",
    region_name="tissue",
    aws_access_key_id="GKa1b2c3d4e5f60718293a4b5c",
    aws_secret_access_key="…",
    config=Config(s3={"addressing_style": "path"}),
)

addressing_style is explicit here because a client that falls back to virtual-host style would try to resolve avatars.g7.tissue.systems, which does not exist.

Write and read an object

s3.put_object(
    Bucket="avatars",
    Key="portraits/alice.jpg",
    Body=open("photo.jpg", "rb"),
    ContentType="image/jpeg",
)

obj = s3.get_object(Bucket="avatars", Key="portraits/alice.jpg")
data = obj["Body"].read()

Upload and download files, with parts and retries handled for you:

s3.upload_file("backup.tar.gz", "backups", "2026-08-09/backup.tar.gz")
s3.download_file("backups", "2026-08-09/backup.tar.gz", "restore.tar.gz")

List every object, paginating past the 1000-key response limit:

paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket="avatars", Prefix="portraits/"):
    for obj in page.get("Contents", []):
        print(obj["Key"], obj["Size"])

Presign a URL for a browser upload or a one-off download:

download = s3.generate_presigned_url(
    "get_object",
    Params={"Bucket": "avatars", "Key": "portraits/alice.jpg"},
    ExpiresIn=3600,
)

upload = s3.generate_presigned_url(
    "put_object",
    Params={"Bucket": "avatars", "Key": "incoming/photo.jpg"},
    ExpiresIn=900,
)

A presigned URL carries its own signature, so the holder needs no credentials and no S3 client. A plain PUT or GET is enough. It stops working when it expires.

Copy and delete

s3.copy_object(
    Bucket="avatars",
    Key="archive/alice.jpg",
    CopySource="avatars/portraits/alice.jpg",
)
s3.delete_object(Bucket="avatars", Key="portraits/alice.jpg")

Other S3 clients

rclone: add a remote to ~/.config/rclone/rclone.conf:

[tissue]
type = s3
provider = Other
endpoint = https://g7.tissue.systems
region = tissue
force_path_style = true
access_key_id = GKa1b2c3d4e5f60718293a4b5c
secret_access_key = …
rclone copy ./dist tissue:site-assets/
rclone sync tissue:backups/ ./restore

Any other client needs the same four things: the endpoint URL, region tissue, path-style addressing, and the bucket's key pair.


How this differs from Amazon S3

A key names its bucket. The bucket segment in a request URL is not what selects the bucket; the access key is. Two consequences:

  • aws s3 ls with no bucket returns nothing. There is no ListBuckets operation, because a key can only ever see one bucket.
  • Typing the wrong bucket name does not fail; the request lands on the key's own bucket. A key cannot reach another bucket by any spelling, so this is a boundary rather than a leak, but a typo in a script is silent.

Path-style addressing only. https://g7.tissue.systems/<bucket>/<key>. Virtual-host style (<bucket>.g7.tissue.systems) does not resolve.

The region is tissue. It is part of the signature, so a client configured for us-east-1 will sign against the wrong scope with some SDKs.

Signatures expire in 15 minutes. A request signed more than 15 minutes away from server time is rejected. If everything returns 403 on a machine that worked yesterday, check its clock first.

100 MiB per request. A single PUT is capped at 100 MiB. Larger objects go up as multipart uploads, where the cap applies per part rather than to the object. The AWS CLI and boto3 do this for you above 8 MB, and there is no ceiling on the finished object.

Bucket-level configuration is not implemented. Versioning, tagging, and object ACLs return NotImplemented. Bucket visibility is a g7 setting rather than an S3 ACL: ribo bucket set <name> --public or --private, described in Managing buckets.


See also