S3 Explorer

  • TypeScript
  • React
  • Express
  • SQLite

S3 Explorer is a self-hosted web UI for every S3-compatible storage provider — AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, Google Cloud, and Railway Buckets. I built it because managing buckets usually means switching between a patchwork of provider dashboards and CLI tools, each with its own quirks. I wanted one interface that worked the same way everywhere, and that I could actually trust with production credentials.

S3 Explorer file manager interface

Problem Statement

Most S3-compatible providers expose their own web consoles, but the UX varies wildly. AWS’s console is dense and enterprise-flavored, MinIO’s is minimal, R2’s lives inside a larger dashboard, and self-hosted MinIO installs often require extra setup just to get a usable file manager. None of these let you hold multiple provider credentials in a single place and switch between them in a click. The CLI is powerful but slow for exploration, and passing credentials around in shell history is a security footgun.

Solution

S3 Explorer is a single Node + React application you can self-host. You log in once with a master password, and from there you can register up to 100 bucket connections (any S3-compatible provider), encrypt all credentials server-side with AES-256-GCM, and browse / upload / download through a consistent, keyboard-friendly UI. Nothing sensitive ever touches the browser’s local storage — credentials stay encrypted at rest and are decrypted on-demand by the server for S3 SDK calls.

Connection manager screen

Architecture

  1. Frontend: React with Tailwind CSS, built by Vite. Contains the file manager UI, connection manager, command palette (Cmd+K), and upload modal. Stateless between sessions — all application state lives on the server.
  2. Backend: Express server written in TypeScript. Handles auth, session management, credential encryption, and proxies all S3 operations through the server-side SDK. The client never sees raw S3 credentials.
  3. Database: SQLite via better-sqlite3. Stores hashed passwords, sessions, and encrypted S3 connection blobs. SQLite keeps the deployment self-contained — no external DB required.
  4. Auth: Single-password login using Argon2id for hashing, with IP-based rate limiting (10 attempts per 15 minutes, 30-minute lockout). Sessions are server-side with httpOnly / secure / sameSite=strict cookies.
  5. Encryption: S3 credentials are encrypted at rest with AES-256-GCM using a server-side key. Decryption happens only when executing an S3 operation — never cached in memory longer than necessary.
  6. Deployment: Dockerfile + Railway one-click deploy. A persistent volume at /data holds the SQLite file.

Features

File management

  1. Drag-and-drop uploads
  2. Create folders, rename, delete files and folders
  3. Batch select + delete
  4. In-browser file preview
  5. Secure server-proxied downloads (no presigned URLs leaking from the browser)

Multi-connection

  1. Store up to 100 S3 connections
  2. Instant switching between them from the connection manager
  3. All credentials encrypted server-side

Keyboard navigation

  1. Cmd+K / Ctrl+K — command palette
  2. Cmd+, / Ctrl+, — connection manager
  3. Cmd+U / Ctrl+U — upload
  4. Escape — close active modal
Command palette (Cmd+K) with actions, connections, and buckets

Notable Learnings

Never trust the browser with credentials

The first version stored S3 credentials client-side and signed requests in the browser. That meant a malicious extension or XSS could exfiltrate production keys. I moved all SDK calls server-side — the client only sees a list of connection names and files. This added one network hop but removed an entire class of attacks.

Encryption key lifecycle

AES-256-GCM requires a stable encryption key across restarts, but I didn’t want to ship a hard-coded key or require the operator to manage a separate KMS. The compromise: the key is derived from a SESSION_SECRET environment variable, backed by a secrets manager (Railway / Docker secrets / etc.). Rotate the env var and you invalidate all stored credentials — the correct behavior for a key leak.

SQLite over Postgres

For a single-user self-hosted tool, SQLite is perfect: no daemon, no network config, no ops burden. better-sqlite3 is synchronous but fast enough for this workload — session lookups and connection list reads are all sub-millisecond. I only considered Postgres briefly before realizing it was over-engineering.

Rate limiting that actually works

The naive rate limiter was per-session — useless, because an attacker never authenticates. The real limiter is per-IP, in-memory, with an exponential lockout. 10 failed attempts in 15 minutes → 30-minute lockout for that IP. Combined with Argon2id’s deliberate slowness, brute force against the master password becomes impractical.

Thank you for reading!