g7: Object Storage/S3 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 is 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 — 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 cannot see or touch anything else in your account, which makes it 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 is not self-serve yet — 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 worth knowing before they surprise you:

  • 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