air3 is a secure file gateway that lets you serve files from a strictly private S3-compatible storage to the public internet — or across segmented internal networks — without ever exposing your S3 credentials and server to edge services or opening any inbound firewall ports to your private storage zone. S3 backend can be hidden behind firewall, NAT or even VPN, with no inbound whatsoever, and still can serve public clients without them and edge applications even know of it's existance.
It acts as a secure bridge across network boundaries (like DMZs or zero-trust environments), relying on a NATS message broker to coordinate transfers and strict zone separation to keep your data safe.
Imagine you have sensitive S3 storage hosted deep within your private infrastructure. You need to let specific public users download certain files via signed URLs.
Normally, you'd have to:
- Generate S3 pre-signed URLs, which grant direct access to your S3 API.
- Open your S3 network to the public internet or put a proxy in a DMZ that holds your highly-privileged S3 credentials.
air3 solves this by splitting the responsibility:
- A Public Edge Gateway sits on the public internet. It validates signed URLs from clients but has zero knowledge of your S3 credentials and no direct access to S3.
- A Private Connector sits in your private network. It securely holds the S3 credentials but has no inbound public access. It only makes outbound connections.
- A NATS Broker acts as a middleman (control plane) passing messages between them.
When a public user requests a file, the Edge Gateway asks the Private Connector (via NATS) to fetch it. The Connector grabs the file from S3 and securely streams it back out to the Edge, which directly delivers it to the user.
air3 isn't just for public-facing traffic. It is equally powerful as an internal bridge in strict zero-trust environments.
If you have isolated Kubernetes clusters, locked-down microservices, or a heavily segmented service mesh, you can deploy the Edge Gateway in the less-trusted zones and the Private Connector in the highly-trusted data zone. This allows your internal services to securely stream objects using short-lived signed URLs without distributing raw S3 credentials to every microservice or punching holes in your storage backbone's network policies.
- Control Plane (NATS): NATS is only used to pass short-lived "fetch tickets" (instructions) from the Edge to the Connector. Object bytes never pass through NATS.
- Data Plane (HTTPS by default): File bytes stream directly from your S3 storage to the Private Connector, and then securely out to the Edge Gateway over an outbound mTLS connection. Experimental TCP, smux, custom QUIC, and HTTP/3 ingest transports are available as opt-in benchmark/runtime tuning modes; HTTP remains the default and fallback.
- No Direct S3 Access: Users get URLs signed for the Edge Gateway, not S3. The Edge handles authorization before the private network even knows about the request.
By default, air3 uses a Single-Server Topology, where URLs look like /{bucket}/{key}. Only the Private Connector knows your S3 credentials, keeping your data highly secure.
You can configure air3 for more advanced deployments:
If you only serve files from a single bucket, having the bucket name in the public URL is redundant. Setting a default bucket (e.g., AIR3_S3_BUCKET=demo) allows clients to request cleaner, shorter URLs like /file.txt.
- Under the hood: The Edge automatically injects the
demobucket into the ticket. The signature validation and the Private Connector's S3 fetch still strictly verify and use thedemobucket.
If you have multiple isolated S3 storage backends (e.g., "blue" storage and "green" storage), enable AIR3_MULTI_SERVER=true.
- Public URLs: URLs change to
/{server_alias}/{bucket}/{key}(e.g.,/blue/demo-bucket/file.txt). You can also use Default Buckets per alias so/blue/file.txtautomatically targets the "blue" server's default bucket. - Routing: The Edge Gateway uses the
{server_alias}to route the ticket to a specific NATS queue (e.g.,air3.blue). The Private Connector configured for that alias only listens to its specific queue.
In specific scenarios where you explicitly want the Edge to connect directly to S3 (bypassing NATS and the Private Connector entirely), you can use AIR3_DIRECT_SERVERS.
- Warning: This places S3 credentials and direct S3 network reachability on the Edge Gateway, stepping outside the primary zero-trust boundary of air3. Only use this if you intentionally accept placing credentials in your DMZ.
# 1. Single-Server Short URL
# The edge uses default bucket "demo". The client uses a short URL path.
AIR3_S3_BUCKET=demo ./bin/edge-gateway
go run ./cmd/signurl \
-base-url https://files.example.com \
-bucket demo \
-key hello.txt \
-secret "$AIR3_SIGNING_SECRET" \
-default-bucket-path# 2. Multi-Server URL
# The URL must include the "blue" alias.
go run ./cmd/signurl \
-base-url https://files.example.com \
-server blue \
-bucket demo-bucket \
-key dir/object.txt \
-secret "$AIR3_SIGNING_SECRET"
# If the Edge has a default bucket configured for "blue" (S3_BLUE_BUCKET=demo-bucket),
# we can emit a shorter URL: /blue/dir/object.txt.
go run ./cmd/signurl \
-base-url https://files.example.com \
-server blue \
-bucket demo-bucket \
-key dir/object.txt \
-secret "$AIR3_SIGNING_SECRET" \
-default-bucket-path
# The Private Connector for this alias will listen to the derived NATS subject (air3.blue)
AIR3_SERVER_NAME=blue ./bin/private-connectorsequenceDiagram
autonumber
participant Client
participant Edge as Edge gateway
participant NATS as NATS
participant Connector as Private connector
participant S3 as S3 storage
Client->>Edge: Signed GET or HEAD
Edge->>Edge: Verify edge URL and create live ticket
Edge->>NATS: Publish ticket (control plane only)
Connector->>NATS: Pull ticket from queue
Connector->>S3: Fetch object bytes or metadata
S3-->>Connector: Return object stream or status
Connector->>Edge: Ingest stream (mTLS + token, HTTP default, TCP/smux/QUIC/HTTP/3 opt-in)
Edge-->>Client: Forward stream to held response
Edge->>Edge: Complete pending request
air3 strictly separates your infrastructure into isolated zones:
flowchart TB
subgraph Public["Public Internet / public network"]
Client["Client or app"]
PublicEdge["Edge public listener\nGET/HEAD HTTPS"]
subgraph Broker["Broker / control plane"]
NATS["NATS broker\nshort-lived tickets"]
Ingest["Edge private ingest listener\nmTLS HTTP /ingest, HTTP/3, TCP, smux, or QUIC"]
end
end
subgraph Private["Private network"]
Connector["Private connector\nno public inbound listener"]
S3["S3-compatible storage\nVersityGW in demo"]
end
Client -->|"public HTTPS"| PublicEdge
PublicEdge -->|"mTLS ticket publish"| NATS
Connector -->|"NATS mTLS control plane connection"| NATS
Connector -->|"S3 private API"| S3
Connector -->|"outbound mTLS ingest stream (HTTP family, TCP, smux, or QUIC)"| Ingest
Ingest -->|"stream handoff inside edge"| PublicEdge
cmd/edge-gateway: The public-facing server. It listens for clientGET/HEADrequests and provides a private mTLS "ingest" listener to receive the file stream from the connector (HTTP by default, with experimental opt-in HTTP/3, TCP, smux, and QUIC).cmd/private-connector: The private worker. It securely holds S3 credentials, pulls tickets from NATS, fetches the files from S3, and pushes them out to the edge gateway.cmd/signurl: A CLI utility for generating signed URLs for your Edge Gateway.internal/*: Core logic for configuration, signing, mTLS, NATS communication, and object fetching.
Want to see it in action? The demo spins up a fully separated environment using Docker Compose.
Prerequisites:
- Go 1.22+
make- Docker with Compose plugin
openssl(for demo certificates)curl(for smoke testing)
The demo creates four isolated services to represent the different network zones:
edge-gateway(Public & Broker networks, exposed onhttps://localhost:8443)private-connector(Broker & Private networks, completely hidden from the host)nats(Broker network with mTLS enabled)versitygwas a mock S3 server (Private network only)
Run the end-to-end demo:
make e2e(This is a shortcut for make certs, make compose-up, make seed, make smoke, and make compose-down)
The base Compose demo sets AIR3_S3_BUCKET=demo, so make smoke signs short single-server URLs such as /hello.txt while tickets and connector fetches still target bucket demo. The smoke tests automatically verify that signed GET/HEAD requests work, short-form default-bucket parsing wins over legacy full-path parsing, expired signatures are rejected, missing objects return 404, and most importantly, that the Edge container cannot bypass the system to connect directly to the private S3 server.
For a runnable multi-server example, use make e2e-multiserver. It exercises blue as a routed alias through NATS plus a Private Connector, and direct as an Edge direct-S3 alias using the deploy/compose.multiserver.yaml overlay. Both aliases set a demo default bucket and the smoke harness signs short-form URLs for them, while retaining a full-path green request to show non-default aliases are unchanged.
We include a comprehensive performance test suite to help you choose the best configuration for your workload. The benchmark spins up the demo stack, seeds sample files (small, medium, and large), and compares air3's secure gateway against a direct S3 connection and a standard Caddy reverse proxy.
Note: The benchmarks expose the private S3 server purely for comparison purposes. In normal operation, air3 keeps your S3 storage strictly private.
To run the full suite across different transports (HTTP/1, HTTP/2, HTTP/3, TCP, SMUX, QUIC), concurrency levels, and scale modes:
make readme-benchmarkResults will be saved in the .air3-perf-results/ directory.
The results below show average Time To First Byte (TTFB in ms) / Requests Per Second (RPS) / Throughput (MiB/s).
(Snapshot generated locally using make readme-benchmark on 2026-06-10).
Based on these results, we recommend:
- For general use & maximum compatibility:
http1orhttp2remain incredibly solid choices. They are well-understood, easy to debug, and provide strong baseline performance without requiring custom transports. - For large file streaming (highest throughput):
tcpandhttp1perform exceptionally well under high concurrency with large files, often matching or exceeding standard HTTP proxies. - For high-concurrency small files:
quicandhttp2handle many small requests very efficiently.
Test environment resource limits: Caddy 2 CPU, Edge Gateway 2 CPU, Private Connector 2 CPU (1 instance).
Sequential Requests
| Target | Small (100KB) | Medium (5MB) | Big (100MB) | Mixed |
|---|---|---|---|---|
| Caddy proxy (baseline) | 4.5 / 23.8 / 3 | 1.8 / 39.3 / 196 | 1.8 / 19.5 / 889 | 2.5 / 25.4 / 429 |
| Air3 (http1) | 6.8 / 9.4 / 1 | 6.1 / 11.0 / 55 | 9.2 / 6.1 / 280 | 7.3 / 7.3 / 124 |
| Air3 (http2) | 13.3 / 5.4 / 1 | 15.8 / 5.1 / 25 | 14.9 / 4.5 / 206 | 6.1 / 11.5 / 194 |
| Air3 (tcp) | 20.3 / 5.2 / 1 | 20.2 / 5.2 / 26 | 22.3 / 5.3 / 243 | 13.2 / 6.4 / 108 |
| Air3 (smux) | 15.4 / 6.6 / 1 | 17.0 / 6.5 / 32 | 18.6 / 5.1 / 231 | 16.2 / 5.8 / 99 |
| Air3 (quic) | 9.1 / 7.4 / 1 | 10.7 / 8.5 / 42 | 17.6 / 4.0 / 184 | 10.3 / 10.0 / 170 |
| Air3 (http3) | 12.5 / 14.6 / 2 | 12.7 / 10.0 / 50 | 14.4 / 3.7 / 171 | 10.0 / 6.8 / 116 |
Concurrent Requests (16 workers)
| Target | Small (100KB) | Medium (5MB) | Big (100MB) | Mixed |
|---|---|---|---|---|
| Caddy proxy (baseline) | 6.4 / 147.8 / 16 | 20.4 / 138.7 / 690 | 41.9 / 66.4 / 3032 | 22.7 / 114.7 / 1821 |
| Air3 (http1) | 13.9 / 61.4 / 6 | 27.7 / 60.0 / 299 | 73.0 / 32.1 / 1466 | 32.5 / 43.9 / 697 |
| Air3 (http2) | 11.2 / 64.6 / 7 | 11.9 / 52.1 / 259 | 19.3 / 14.3 / 651 | 15.5 / 32.3 / 513 |
| Air3 (tcp) | 20.7 / 63.7 / 7 | 31.0 / 75.1 / 374 | 82.4 / 27.9 / 1276 | 32.0 / 42.4 / 674 |
| Air3 (smux) | 19.3 / 67.3 / 7 | 31.5 / 56.1 / 279 | 64.8 / 23.5 / 1072 | 34.0 / 38.7 / 613 |
| Air3 (quic) | 20.5 / 72.6 / 8 | 57.0 / 31.5 / 157 | 52.4 / 13.2 / 604 | 26.6 / 27.5 / 437 |
| Air3 (http3) | 29.6 / 58.1 / 6 | 42.3 / 26.0 / 130 | 47.4 / 8.6 / 391 | 24.7 / 21.9 / 348 |
Test environment resource limits: Caddy 4 CPU, Edge Gateway 4 CPU, Private Connector 2 CPU (3 instances, 6 CPU total).
Sequential Requests
| Target | Small (100KB) | Medium (5MB) | Big (100MB) | Mixed |
|---|---|---|---|---|
| Caddy proxy (baseline) | 4.6 / 22.7 / 2 | 2.1 / 27.4 / 136 | 1.7 / 21.3 / 975 | 1.9 / 29.4 / 498 |
| Air3 (http1) | 6.5 / 14.3 / 2 | 12.7 / 5.9 / 29 | 9.7 / 6.9 / 316 | 5.0 / 12.9 / 219 |
| Air3 (http2) | 12.5 / 7.9 / 1 | 5.9 / 10.1 / 50 | 9.5 / 6.0 / 275 | 8.4 / 5.6 / 95 |
| Air3 (tcp) | 15.5 / 6.9 / 1 | 19.7 / 5.4 / 27 | 15.8 / 6.3 / 289 | 15.2 / 6.1 / 104 |
| Air3 (smux) | 13.0 / 10.3 / 1 | 24.3 / 6.3 / 31 | 17.0 / 5.4 / 249 | 18.9 / 6.0 / 101 |
| Air3 (quic) | 17.8 / 5.7 / 1 | 18.3 / 5.2 / 26 | 9.2 / 6.5 / 298 | 18.9 / 4.6 / 78 |
| Air3 (http3) | 8.4 / 11.7 / 1 | 9.8 / 6.3 / 31 | 20.2 / 2.4 / 111 | 19.3 / 4.2 / 71 |
Concurrent Requests (16 workers)
| Target | Small (100KB) | Medium (5MB) | Big (100MB) | Mixed |
|---|---|---|---|---|
| Caddy proxy (baseline) | 5.4 / 169.4 / 18 | 9.5 / 145.3 / 723 | 25.7 / 83.3 / 3805 | 11.9 / 100.0 / 1588 |
| Air3 (http1) | 8.2 / 70.7 / 7 | 15.3 / 62.0 / 308 | 44.5 / 35.4 / 1617 | 18.8 / 50.9 / 808 |
| Air3 (http2) | 7.8 / 71.5 / 8 | 13.9 / 71.3 / 355 | 11.3 / 29.9 / 1365 | 11.1 / 43.1 / 684 |
| Air3 (tcp) | 13.4 / 69.0 / 7 | 22.4 / 70.5 / 351 | 42.3 / 38.8 / 1773 | 41.3 / 42.6 / 676 |
| Air3 (smux) | 15.2 / 67.9 / 7 | 25.4 / 57.5 / 286 | 48.4 / 31.1 / 1420 | 22.5 / 44.4 / 704 |
| Air3 (quic) | 15.0 / 95.8 / 10 | 16.6 / 41.8 / 208 | 29.7 / 17.7 / 809 | 16.5 / 32.7 / 519 |
| Air3 (http3) | 15.1 / 75.8 / 8 | 48.2 / 31.0 / 154 | 22.4 / 13.8 / 629 | 15.5 / 25.4 / 404 |
Advanced Benchmark Configuration (for developers)
You can deeply customize the benchmark runs via environment variables:
- Transports: Set
AIR3_INGEST_TRANSPORTtohttp1,http2,http3,tcp,smux, orquic. - Concurrency & Scale: Use
AIR3_PERF_ITERATIONS,AIR3_PERF_CONNECTORS,AIR3_PERF_MULTI_CONNECTORS, andAIR3_PERF_PARALLELISM. - Tuning: Adjust
AIR3_STREAM_COPY_BUFFER_BYTES,AIR3_INGEST_POOL_SIZE, andAIR3_CONNECTOR_WORKERSto see how internal buffering and pooling affects throughput.
Example commands:
make perf # single connector, 5 iterations per object
AIR3_PERF_PARALLELISM=8 make perf # adds a parallel phase
make perf-multi # tests scaled mode (3 connectors)The perf override limits each edge-gateway and private-connector container to one CPU and raises both AIR3_CONNECTOR_WORKERS and AIR3_INGEST_POOL_SIZE to 1024 unless overridden. It also adds the caddy-s3 service and deploy/Caddyfile.perf for the Caddy baseline. Unsigned public baselines use curl against the perf-exposed endpoints; gateway measurements use cmd/signurl and curl --http1.1 --cacert deploy/certs/generated/dev-ca.crt against https://localhost:8443 for stable streaming timings. Custom stream transports (tcp, smux, quic) use the shared MessagePack metadata frame, raw object body, and ack semantics; smux multiplexes those streams over persistent mTLS TCP, one smux stream per object.
To allow users to download files, your backend application must generate an edge signed URL. These use a standard HMAC signature. Since the gateway verifies the signature before generating a ticket, bogus requests are dropped at the edge—saving your private network from dealing with malicious traffic.
We provide drop-in packages for Go, TypeScript, and Python under the packages/ directory. The TypeScript package is published to the public npm registry as @terion/air3-edgesign:
npm install @terion/air3-edgesignraw, err := edgesign.SignURL(edgesign.SignInput{
Method: "GET",
BaseURL: "https://files.example.com",
// Server: "blue", // optional: emits /blue/{bucket}/{key} for multi-server mode
Bucket: "demo-bucket",
Key: "dir/object.txt",
Secret: signingSecret,
Expires: time.Now().Add(15 * time.Minute),
Range: "bytes=0-99", // optional
})import { signUrl, verifyUrl } from '@terion/air3-edgesign';
const raw = signUrl({
method: 'GET',
baseUrl: 'https://files.example.com',
// server: 'blue', // optional: emits /blue/{bucket}/{key} for multi-server mode
bucket: 'demo-bucket',
key: 'dir/object.txt',
secret: signingSecret,
expires: Math.floor(Date.now() / 1000) + 900,
range: 'bytes=0-99', // optional
});from datetime import datetime, timedelta, timezone
from edgesign import sign_url, verify_url
raw = sign_url(
method="GET",
base_url="https://files.example.com",
# server="blue", # optional: emits /blue/{bucket}/{key} for multi-server mode
bucket="demo-bucket",
key="dir/object.txt",
secret=signing_secret,
expires=datetime.now(timezone.utc) + timedelta(minutes=15),
range="bytes=0-99", # optional
)If you need to serve partial files (like for streaming video), air3 fully supports standard HTTP Range headers.
If you're using signed URLs and expect clients to send Range headers, you must include the exact range claim when generating the URL (e.g., cmd/signurl -range 'bytes=0-99'). The Connector forwards authorized ranges to S3, seamlessly returning a 206 Partial Content response to the client.
While air3 primarily relies on its highly-secure HMAC signed URLs, you can optionally enable a path-style S3-compatible API (AIR3_S3_API_ENABLED=true). It is read-only by default and is useful if your clients or internal tools already use standard S3 SDKs (like aws-cli or boto3) and you want them to fetch files without modifying their code.
- No Real S3 Credentials at the Edge: You must define a brand new, gateway-only set of credentials (
AIR3_S3_API_ACCESS_KEY_IDandAIR3_S3_API_SECRET_ACCESS_KEY). The Edge Gateway only uses these to verify incoming AWS SigV4 requests. It does not give the Edge access to the backend storage. These are completely separate from your real, private S3 credentials and your HMACAIR3_SIGNING_SECRET. - Supported Operations: The API supports read paths
GET/GetObject,HEAD/HeadObject,ListObjectsV2, andHeadBucket, plus gated mutationsPutObject,DeleteObject, and multipart upload (CreateMultipartUpload,UploadPart,CompleteMultipartUpload,AbortMultipartUpload) — enough foraws s3 cpof large files. ACLs, tagging, versioning,ListParts, andListMultipartUploadsare unsupported. - Mutations Off by Default: S3-compatible mutation handling must be explicitly gated with
MUTATIONS_ENABLED=true. - Works with Default Client Settings: Uploads accept
x-amz-content-sha256: UNSIGNED-PAYLOAD, a signed SHA-256 payload hash, or theSTREAMING-UNSIGNED-PAYLOAD-TRAILERaws-chunked mode that modern AWS CLI/SDK versions use by default (the framing is decoded at the edge). Two caveats: a signed payload hash is verified as part of the request signature but the streamed body is not re-hashed against it, and aws-chunked checksum trailers (x-amz-checksum-*) are discarded, not verified — end-to-end body integrity relies on TLS. Signed-chunk streaming (STREAMING-AWS4-HMAC-SHA256-PAYLOAD*) stays rejected; clients only use it on plain-HTTP endpoints. - Independent Edge/Connector Gates: Routed mutations require
MUTATIONS_ENABLED=trueon both the Edge and the target Connector. Direct-server aliases bypass the Connector, so direct alias mutations require only the Edge gate.AIR3_MUTATIONS_ENABLEDremains as a compatibility alias; if both names are non-empty, their boolean values must match.
All API requests must use path-style addressing. Depending on your routing mode, the "bucket" in the S3 API request behaves differently:
# 1. Single-Server Mode
# The API bucket exactly matches your backend bucket name.
aws --endpoint-url https://files.example.com s3api get-object \
--bucket demo --key photos/cat.jpg cat.jpg
# 2. Multi-Server Mode (Standard)
# The API bucket is the server alias ("blue").
# The first folder in the key ("demo/") maps to the backend bucket name.
aws --endpoint-url https://files.example.com s3api get-object \
--bucket blue --key demo/photos/cat.jpg cat.jpg
# 3. Multi-Server Mode (With Default Bucket)
# If server "blue" has a default bucket configured, the key maps directly.
aws --endpoint-url https://files.example.com s3api get-object \
--bucket blue --key photos/cat.jpg cat.jpg(See docs/configuration.md for the full configuration details and security model.)
make validate # format, test, build, and check compose config
make fmt # format Go code
make test # run Go tests
make build # build binaries into ./bin
make ts-test # install, test, and build the TypeScript package
make python-test # run Python package tests
go test ./... -race # run race-enabled Go testsWe publish multi-architecture Docker images (Linux on amd64 and arm64) to the GitHub Container Registry on every release. Cross-compiled binaries for macOS, Windows, and Linux are also attached to GitHub releases. The same tag workflow publishes the public npm package @terion/air3-edgesign to npmjs with the tag as the npm version. Publishing uses npm Trusted Publishing from .github/workflows/release.yml, so the GitHub Actions job authenticates to npm through OIDC instead of an npm token.
ghcr.io/terion-name/air3/edge-gateway:<tag>ghcr.io/terion-name/air3/private-connector:<tag>ghcr.io/terion-name/air3/signurl:<tag>
You can build local images using the root Dockerfile by setting the APP argument:
docker build --build-arg APP=edge-gateway -t air3-edge-gateway:local .
docker build --build-arg APP=private-connector -t air3-private-connector:local .
docker build --build-arg APP=signurl -t air3-signurl:local .