Skip to main content

nanio

CI PyPI version Python versions License: Apache 2.0 Coverage: 100% Ruff

A minimal, stateless, S3-compatible object storage server.

Buckets are folders, objects are files, the entire backing store is a flat POSIX filesystem. Install with pipx, run with one command, point any official S3 client at it.

pipx install nanio
export NANIO_ACCESS_KEY=minioadmin NANIO_SECRET_KEY=minioadmin
nanio serve --data-dir ./nanio-data

In another shell, point the AWS CLI at it. The aws CLI only reads its own AWS_* env vars — it doesn't know about NANIO_* — so you have to export the same credentials under the names the AWS SDK expects:

export AWS_ACCESS_KEY_ID=minioadmin
export AWS_SECRET_ACCESS_KEY=minioadmin
export AWS_DEFAULT_REGION=us-east-1

aws --endpoint-url http://localhost:9000 s3 mb s3://test
aws --endpoint-url http://localhost:9000 s3 cp README.md s3://test/
aws --endpoint-url http://localhost:9000 s3 ls s3://test/

That's it. The official aws-cli, boto3, and s3cmd Just Work against nanio with no special-casing.

Why nanio

Early MinIO was beautifully simple: a single binary, a single command, an S3-compatible HTTP server backed by the filesystem. Modern MinIO has grown into a feature-rich product with erasure coding, IAM, lifecycle policies, replication, and a console UI. That's the right call for them — and the wrong call for the dozens of small use cases that just need an S3 endpoint in front of a directory: local development, CI fixtures, edge caches, short-lived test environments, simple backup targets.

nanio is the small thing. It does the S3 wire protocol over a flat filesystem, and nothing else. The whole codebase is a few thousand lines of Python. There is no console, no IAM, no versioning, no lifecycle, no replication, no encryption-at-rest. There is one CLI command (nanio serve) and two environment variables.

Features

  • ✅ S3 wire compatibility for the operations the official clients actually use:
    • ListBuckets, CreateBucket, DeleteBucket, HeadBucket, GetBucketLocation
    • PutObject, GetObject, HeadObject, DeleteObject, DeleteObjects (batch)
    • ListObjectsV2 with prefix, delimiter, pagination, encoding-type
    • CopyObject
    • Multipart upload: CreateMultipartUpload, UploadPart, CompleteMultipartUpload, AbortMultipartUpload, ListParts, ListMultipartUploads
    • Presigned URLs for GET and PUT
    • Range requests (206 Partial Content)
  • ✅ AWS Signature V4 verification (header form + presigned URLs + streaming STREAMING-AWS4-HMAC-SHA256-PAYLOAD)
  • ✅ Streaming uploads and downloads — server memory does not scale with object size
  • ✅ Stateless — point N processes at a shared filesystem and put any TCP load balancer in front
  • ✅ Single-user (env vars) or multi-user (TOML credentials file)

Non-features

These are deliberately out of scope. nanio is intentionally small.

  • ❌ No TLS — terminate HTTPS upstream (nginx, caddy, traefik)
  • ❌ No IAM, no policies, no ACLs — credentials are bearer tokens
  • ❌ No versioning, lifecycle, replication, or event notifications
  • ❌ No web console, no admin API, no metrics endpoint (stick a reverse proxy with metrics in front)
  • ❌ No encryption at rest — use a filesystem that does it (LUKS, dm-crypt, ZFS native)
  • ❌ No erasure coding — use a filesystem that does it (ZFS, btrfs RAID, mdraid)
  • ❌ Not supported on Windows (POSIX rename semantics, os.pread)

If you need any of those, run real MinIO, Ceph RGW, or AWS S3.

Installation

pipx install nanio

Or via uv:

uv tool install nanio

Both options put a nanio binary on your $PATH.

Docker

docker run -d --name nanio \
  -p 9000:9000 \
  -e NANIO_ACCESS_KEY=minioadmin \
  -e NANIO_SECRET_KEY=minioadmin \
  -v nanio-data:/data \
  ghcr.io/apocas/nanio:latest

The image stores data in /data (a named volume above) and runs as a non-root user (uid 1000). Anything after the image name is passed to the nanio CLI: docker run --rm ghcr.io/apocas/nanio --version, or ... ghcr.io/apocas/nanio:latest serve --workers 4.

Or with compose:

services:
  nanio:
    image: ghcr.io/apocas/nanio:latest
    ports:
      - "9000:9000"
    environment:
      NANIO_ACCESS_KEY: minioadmin
      NANIO_SECRET_KEY: minioadmin
    volumes:
      - nanio-data:/data

volumes:
  nanio-data:

For multi-user credentials, mount an options file and point NANIO_OPTIONS_FILE at it:

docker run -d --name nanio \
  -p 9000:9000 \
  -v ./nanio-options.toml:/etc/nanio/options.toml:ro \
  -e NANIO_OPTIONS_FILE=/etc/nanio/options.toml \
  -v nanio-data:/data \
  ghcr.io/apocas/nanio:latest

Leave data_dir out of the file's [server] section — inside the container the data directory is always /data (the image sets NANIO_DATA_DIR, which takes precedence over the options file).

To bind-mount a host directory instead of a named volume, make it writable by uid 1000 (chown 1000 ./data) or run the container as yourself: --user "$(id -u):$(id -g)".

Configuration

nanio ships with a single subcommand: serve.

nanio serve [OPTIONS]

Options:
  --options PATH             TOML options file with [server] settings and
                             [[users]] credentials   [env: NANIO_OPTIONS_FILE]
  --data-dir PATH            Root directory for buckets   [env: NANIO_DATA_DIR]   default: ./nanio-data
  --host TEXT                Bind host                    [env: NANIO_HOST]       default: 0.0.0.0
  --port INTEGER             Bind port                    [env: NANIO_PORT]       default: 9000
  --workers INTEGER          uvicorn workers              [env: NANIO_WORKERS]    default: 1
  --region TEXT              S3 region to report          [env: NANIO_REGION]     default: us-east-1
  --log-level [debug|info|warning|error]                  default: info
  --no-access-log            Disable per-request logs
  --version
  --help

Configuration sources are merged with this precedence: CLI flag > NANIO_* env var > options file > built-in default.

Single-user (env vars)

export NANIO_ACCESS_KEY=minioadmin
export NANIO_SECRET_KEY=minioadmin
nanio serve --data-dir ./data

Multi-user (options file)

The unified options file holds both server tunables and credentials:

# nanio-options.toml
[server]
data_dir = "/var/lib/nanio"
host     = "0.0.0.0"
port     = 9000
region   = "us-east-1"

[[users]]
access_key = "alice"
secret_key = "alice-very-long-secret"

[[users]]
access_key = "bob"
secret_key = "bob-very-long-secret"
nanio serve --options nanio-options.toml

CLI flags work alongside the file — nanio serve --options options.toml --port 8080 overrides the port from the file, for example.

If neither env vars nor an options file are configured, nanio refuses to start. There is no anonymous mode.

Scaling out

nanio holds zero in-process state. You scale it horizontally by:

  1. Putting all --data-dirs on a shared filesystem (NFSv4, cephfs, or any POSIX-compliant network mount with atomic rename).
  2. Running nanio serve on N machines pointing at that mount.
  3. Putting any TCP load balancer in front (nginx, HAProxy, AWS NLB).
upstream nanio {
    server node1:9000;
    server node2:9000;
    server node3:9000;
}

server {
    listen 443 ssl http2;
    server_name s3.example.com;

    ssl_certificate /etc/ssl/example.com.pem;
    ssl_certificate_key /etc/ssl/example.com.key;

    client_max_body_size 0;          # let nanio handle huge uploads
    proxy_request_buffering off;     # stream the request body
    proxy_buffering off;             # stream the response body

    location / {
        proxy_pass http://nanio;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

All nanio nodes must run NTP/chrony — SigV4 enforces a 15-minute clock-skew window between client and server, and we do not widen it.

Storage layout

Everything under --data-dir is browsable with normal Unix tools:

nanio-data/
├── widgets/                          # bucket
│   ├── photos/2026/cat.jpg           # object
│   ├── data.bin                      # object
│   └── .nanio-meta/                  # sidecar metadata (one .json per object)
│       ├── photos/2026/cat.jpg.json
│       └── data.bin.json
└── .nanio/
    └── multipart/                    # in-progress multipart uploads
        └── <upload-id>/
            ├── init.json
            └── parts/000001.bin

You can cat, cp, rsync, and tar your data directly. Backups are just filesystem backups.

Performance notes

nanio uses os.scandir for listing and os.pread/os.sendfile for streaming I/O. It has been validated with locust load tests at hundreds of requests per second per worker. See tests/load/README.md for the scenarios and how to run them.

A single bucket with millions of objects in a single directory is the filesystem's problem, not nanio's. ext4 and XFS handle millions of entries with htree, but performance degrades past a few million entries in one directory. The standard fix is the same as on AWS S3: use prefixed keys (logs/2026/04/08/... instead of one flat directory).

License

Apache 2.0. See LICENSE.

Metadata

Release files for nanio 0.1.7

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for nanio 0.1.7
File Size Uploaded
nanio-0.1.7.tar.gz 49.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for nanio 0.1.7
File Interpreter ABI Platform
nanio-0.1.7-py3-none-any.whl Python 3 none any Details

Total release size: 113.0 kB

Release files / nanio-0.1.7.tar.gz

Download URL nanio-0.1.7.tar.gz
Size 49.9 kB
Tags Source
SHA-256 checksum
How to use checksums
6964ed033acd5b2a0b8c4b1961d73b758b37aea15bffb030cf2b942f2d3de6aa
BLAKE2b-256 checksum
How to use checksums
6e25b6f0da2214a1200a2346e4a18a3c78d997c3974e93dcb4d2d2bcb8c36c2d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 31, 2026.

Transparency log

Release files / nanio-0.1.7-py3-none-any.whl

Download URL nanio-0.1.7-py3-none-any.whl
Size 63.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ce56c674b02cbe7100d1b57619bd962241adf76dc28b2aebc8b7a4ab560118ea
BLAKE2b-256 checksum
How to use checksums
79a039ddbfbf80418824dfa6402a428866280a42e445de936215fb4984b41b88
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 31, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.7 This release

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page