Skip to content
FreeToolNet
Go back

A single-user notes app on FastAPI with files in an S3 bucket

I wanted a private place to drop notes, code snippets and files, reachable from any browser. One user, one password, no accounts system.

The result is a small FastAPI app in a container with a 256 MB memory cap. Note text lives in SQLite. Uploaded files live in a bucket on the S3-compatible storage (RustFS) I already run on the same VPS. The app has its own storage key that can only reach its own bucket, and the browser never talks to S3 directly.

How the app works

Any address is a note. I sign in and open /meeting-notes. If that note exists it loads. If not, a blank one opens. Each note is one of four types:

TypeEditorStored as
Rich textQuill 2.0.3HTML
Plain notetextareatext
CodeCodeMirror 5.65.18, 11 languagestext + language
Filesdrag and drop, up to 100 MB eachobjects in the S3 bucket

SQLite holds two tables: notes keyed by slug, and files, which records the S3 key, name, size and content type of each upload. The bytes themselves are only in the bucket.

A storage key limited to one bucket

The storage server has a root key pair. I did not want the notes app to hold it. If the app were compromised, the attacker would get every bucket.

So the deploy script creates a policy, a user and a binding through the RustFS admin API, using curl with SigV4 signing. $ROOT is the root pair read from the storage server’s env file. It is used once here and never given to the app.

RUSTFS_ADMIN=http://127.0.0.1:9000/rustfs/admin/v3

admin() {  # method path body: a signed RustFS admin API call as the root user
  curl -fsS -X "$1" --aws-sigv4 'aws:amz:us-east-1:s3' --user "$ROOT" \
    -H 'Content-Type: application/json' --data-binary "$3" "$RUSTFS_ADMIN$2" >/dev/null
}
admin PUT "/add-canned-policy?name=$BUCKET-rw" \
  "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:*\"],\"Resource\":[\"arn:aws:s3:::$BUCKET\",\"arn:aws:s3:::$BUCKET/*\"]}]}"
admin PUT "/add-user?accessKey=$S3_USER" "{\"secretKey\":\"$APP_SECRET\",\"status\":\"enabled\"}"
admin PUT "/set-user-or-group-policy?policyName=$BUCKET-rw&userOrGroup=$S3_USER&isGroup=false" ""

The policy allows s3:*, but only on the one bucket and its objects. The new key and secret are appended to the app’s env file, along with S3_ENDPOINT=http://rustfs:9000. That endpoint is the Docker network name, so storage traffic never leaves the server.

This block runs only if the env file has no S3_ACCESS_KEY line yet, so re-running the script does not create a second user.

The storage client

The whole S3 layer is one small class around boto3:

class S3Storage:
    def __init__(self):
        self.bucket = os.environ.get("S3_BUCKET", "notes")
        self.client = boto3.client(
            "s3",
            endpoint_url=os.environ["S3_ENDPOINT"],
            aws_access_key_id=os.environ["S3_ACCESS_KEY"],
            aws_secret_access_key=os.environ["S3_SECRET_KEY"],
            region_name=os.environ.get("S3_REGION", "us-east-1"),
            config=Config(
                s3={"addressing_style": "path"},
                signature_version="s3v4",
                # Only send/check checksums where S3 requires them; not every S3-compatible server
                # supports the newer default checksum headers.
                request_checksum_calculation="when_required",
                response_checksum_validation="when_required",
                retries={"max_attempts": 3},
            ),
        )

Two settings matter for any self-hosted S3 server:

  • addressing_style: path. The bucket goes in the URL path, not the hostname. There is no DNS name per bucket here.
  • The two when_required checksum settings. Recent boto3 sends extra checksum headers by default. Not every S3-compatible server supports them. Setting both to when_required keeps boto3 to the checksums the S3 API demands.

Downloads go through the app

The browser never gets an S3 URL. Every download is GET /files/<id> on the app, which checks the session and then streams the object:

def open(self, key, chunk=256 * 1024):
    """Starts the download (so a missing object fails here, not mid-response); returns a chunk iterator."""
    body = self.client.get_object(Bucket=self.bucket, Key=key)["Body"]

    def chunks():
        try:
            yield from body.iter_chunks(chunk)
        finally:
            body.close()
    return chunks()

get_object is called before the generator is returned. That ordering is deliberate. If the object is missing or storage is down, the exception is raised while the route can still return a clean 502. If the call were inside the generator, the failure would happen after the response headers were already sent.

The route wraps it in a StreamingResponse and picks the Content-Disposition:

# Shown in the browser; anything else (HTML, SVG, scripts) is always a download
INLINE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}

Only those types open inline. An uploaded HTML file is always served as an attachment, so it cannot run script on the app’s origin. There is a test for exactly that.

Uploads are checked for size, given a sanitised name, and stored under <slug>/<id>-<name>:

size = file.file.seek(0, 2)  # the upload is already spooled to disk; measure it
file.file.seek(0)
if size > MAX_UPLOAD:
    raise HTTPException(413, "Files can be up to 100 MB.")
name = _safe_name(file.filename)
file_id = secrets.token_hex(8)
key = f"{slug}/{file_id}-{name}"

If storage is unreachable at startup, the app logs a warning and still starts. Notes keep working and uploads report the problem.

Login for exactly one user

There is no user table. The username, an scrypt password hash and a signing secret come from an env file.

def hash_password(password):
    salt = secrets.token_bytes(16)
    digest = hashlib.scrypt(password.encode(), salt=salt, n=2 ** 14, r=8, p=1)
    return f"scrypt:16384:8:1:{salt.hex()}:{digest.hex()}"

The session is a signed cookie from itsdangerous that lasts 30 days. It embeds the last 12 characters of the current hash:

def new_session():
    # Tied to the current hash, so changing the password signs out every session.
    return _serializer().dumps({"u": _user(), "v": _stored()[-12:]})

Changing the password changes the hash, and every old cookie stops validating. No session store needed.

Guessing is limited in two places. The app locks an IP out for 15 minutes after five wrong passwords. nginx also limits /login:

limit_req_zone $binary_remote_addr zone=notes_login:1m rate=10r/m;

    location = /login {
        limit_req zone=notes_login burst=5 nodelay;
        limit_req_status 429;
        proxy_pass http://127.0.0.1:8500;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

For the per-IP lockout to work behind a proxy, the app has to see the real client address. Uvicorn runs with --proxy-headers --forwarded-allow-ips "*". Trusting every forwarder is acceptable only because the container port is bound to 127.0.0.1, so nginx on the host is the only client.

Write requests to /api/ also need an X-Requested-With: notes header. A browser cannot add that header cross-site without CORS, so it blocks CSRF without tokens.

nginx body size

The app allows 100 MB per file. nginx is set a little above that, and request buffering is off so uploads are not spooled twice:

    client_max_body_size 110m;
    ...
        proxy_request_buffering off;
        proxy_read_timeout 600s;
        proxy_send_timeout 600s;

If nginx’s limit were at or below the app’s limit, large uploads would fail at the proxy with a generic error instead of the app’s own message.

Gotchas

The CSP breaks string-based Playwright waits. The app sends a Content-Security-Policy with no unsafe-eval. When I tested the pages with Playwright, wait_for_function with a string argument failed, because evaluating a string counts as eval. Creating the test context with bypass_csp=True fixed it.

The deploy check must test storage for real. A container that answers /healthz says nothing about whether the scoped key works. The storage class has a selftest that writes a tiny object, reads it back and deletes it. The deploy script runs it inside the container as its last step:

echo "== app -> file storage: $(docker exec notes python -c 'from app.storage import S3Storage; print("OK" if S3Storage().selftest() else "MISMATCH")' 2>&1 | tail -1)"

That one line exercises the endpoint, the key, the policy and the bucket.

Unit tests do not need S3. The storage object hangs off app.state, so tests swap in an in-memory fake with the same put, open and delete methods. The fake has a fail flag, which is how I test that a storage outage returns a 502 and does not create an empty note.

Running it

The container runs as a non-root user and is capped:

docker run -d --name notes --restart unless-stopped --memory 256m --pids-limit 256 \
  --network $NET -p 127.0.0.1:8500:8000 --env-file /etc/notes.env -v /srv/notes/data:/data $IMG >/dev/null

Only the SQLite folder is mounted. Files are in the bucket, so the container itself holds no state. Redeploying is a rebuild and a recreate.

How this post was made

Written with AI assistance and reviewed by the author before publishing. See the editorial policy.

Commands and output were captured on 2026-11-02 on Hostinger VPS; Docker container behind nginx with a certbot certificate; RustFS for S3 storage on the same Docker network (python:3.12-slim, fastapi 0.115.6, uvicorn 0.34.0, boto3 1.43.123, itsdangerous 2.2.0, rustfs/rustfs:1.0.4).



Previous Post
RustFS console behind nginx: broken login and 429 errors, fixed
Next Post
Running a GPU-first Gradio app CPU-only on a 2 vCPU VPS