Self-host SnapPwd
The SnapPwd API, a self-hosted web client, the CLI and the agent skill are open source under the MIT license. You can run the API, Redis and the web client on your own machine with Docker Compose in a few minutes. This guide shows how, and what to configure before anyone else can reach your instance.
Commands on this page were last run on against the main branch of each repository. We make SnapPwd, so read the limitations section before you rely on it.
What is open source and what is not
Four repositories are public and MIT licensed. Together they are everything you need to run your own instance.
| Repository | What it is | License |
|---|---|---|
| snappwd-service | The backend API, written in Rust with Axum. Stores encrypted payloads in Redis. | MIT |
| snappwd-web | A static web client in plain HTML, CSS and JavaScript, with no build step. | MIT |
| snappwd-cli | The command-line client, published on npm as @snappwd/cli. | MIT |
| snappwd-skill | An agent skill that teaches AI coding assistants to share secrets and files through SnapPwd. | MIT |
The web app at www.snappwd.io is a separate application and its source is not public. It uses the same API service (the snappwd-service README states this), but the self-hosted web client is a different and much smaller codebase. Self-hosting gives you the open-source components listed above. It does not give you a copy of the hosted site, and the two web clients do not have the same features (see known limitations).
Architecture
A self-hosted instance has three parts:
- Web client. Static files served by nginx. It encrypts in the browser with the Web Crypto API (AES-GCM) and calls the API directly.
- API. A single Rust binary. It validates requests, applies write limits and reads and writes Redis. It needs no other database.
- Redis. Version 6.2 or later (the service uses
GETDELfor one-time retrieval). Every record has a time to live, so data expires without a cleanup job.
What the server stores
- The ciphertext your client uploads, the creation time and the expiry (60 seconds to 30 days).
- Metadata, in plaintext. For files this is the original filename, the content type and the initialization vector. For secrets it is any optional
metadataa client sends, such as a label. Anyone who has the ID can read this metadata with?peek=true, without the key. Filenames and labels often reveal what a secret is, so leave sensitive details out. - A sender email address, only if a client asks for a reveal notification. It is kept in the same record, never returned by the API, and deleted with the record.
What the server never receives
- The plaintext of a secret or file.
- The decryption key. The clients put it in the URL fragment (after
#), which browsers do not send to servers, and the CLI never includes it in a request.
This holds for the open-source clients. The API stores whatever string it is given, so a client that skips encryption would store plaintext. The security architecture page describes the model, and the API reference lists every field.
Quick start: the full stack
You need Docker with the Compose plugin, and git. Ports 80 and 8080 must be free. The compose file in snappwd-web builds the API from a sibling directory, so clone both repositories side by side:
mkdir snappwd-selfhosted && cd snappwd-selfhosted
git clone https://github.com/SnapPwd/snappwd-web.git
git clone https://github.com/SnapPwd/snappwd-service.git
cd snappwd-web
docker compose up -dThe first run compiles the Rust service, which takes a few minutes. When it finishes, three containers are running:
| Service | Address | Notes |
|---|---|---|
| web | http://localhost | The web client (nginx, port 80). |
| api | http://localhost:8080 | The API. |
| redis | not published | Reachable only from the other containers. |
Open http://localhost, type a secret, and choose Encrypt & Create Link. The link looks like http://localhost/?id=sps-...#key=.... Opening it shows a confirmation screen first. The secret is fetched and deleted only when you choose to reveal it, and a second visit reports that the secret was not found or already viewed.
To stop the stack:
docker compose down # keep the Redis volume
docker compose down -v # also delete the Redis volumeThis compose file is for trying SnapPwd out
redis:alpine image with a redis_data volume and no Redis configuration, so Redis keeps its defaults: snapshots are written to disk and there is no memory limit. Ciphertext can therefore outlive a container restart, up to its expiry. For anything beyond evaluation, give Redis the settings shown in going beyond localhost.API only, with the CLI
If you only need the API, for example to use it from the CLI or your own client, start it from the service repository. This compose file also loads a redis.conf with a memory limit, an eviction policy and persistence turned off.
git clone https://github.com/SnapPwd/snappwd-service.git
cd snappwd-service
docker compose up --build -dThe API listens on http://localhost:8080. Store a secret and read it back with the CLI (needs Node.js):
npx @snappwd/cli put "hello" --api-url http://localhost:8080/v1
# Secret created successfully!
# URL: http://localhost:8080/v1/g/sps-...#...
npx @snappwd/cli get "<the URL printed above>" --api-url http://localhost:8080/v1
# helloRunning get a second time fails with API Error (404): Secret not found or already accessed, because retrieval deletes the record.
Configuration reference
The service is configured with environment variables. Defaults apply when a variable is unset.
| Variable | Purpose | Default |
|---|---|---|
PORT | HTTP port to listen on. | 8080 |
REDIS_URL | Redis connection string. | redis://127.0.0.1:6379 |
MAX_FILE_SIZE_MB | Largest encrypted file accepted, measured before base64 encoding. | 2 |
WRITE_REQUESTS_PER_MINUTE | Write attempts allowed per client IP in a 60-second window, shared by both POST routes and across replicas. | 10 |
STORAGE_MAX_BYTES | Redis memory level above which new writes are refused. | 134217728 (128 MiB) |
STORAGE_MAX_KEYS | Largest number of keys in the Redis database, including rate-limit keys. | 10000 |
WRITE_API_TOKEN | Bearer token required on both POST routes. At least 32 non-space ASCII characters. | unset (anyone can write) |
TRUSTED_PROXY_IPS | Comma-separated IP addresses of reverse proxies allowed to supply the client IP in X-Real-IP. | empty |
CLIENT_INFO_TOKEN | Lets a trusted frontend that retrieves on behalf of visitors describe the visitor in reveal notifications. At least 32 non-space ASCII characters. | unset |
SMTP_HOST, SMTP_FROM, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD | Outgoing mail for sender notifications. | unset (notifications off); port 587 |
RUST_LOG | Log level, such as debug or info. | info |
The web client has one setting:
| Variable | Purpose | Default |
|---|---|---|
API_URL | The address the browser uses to reach the API. It must be reachable from the visitor, not just from the web container. | http://localhost:8080 (set in docker-compose.yml) |
In Docker, API_URL is written into env.js when the container starts. Without Docker, edit env.js yourself. The copy in the repository points at https://api.snappwd.io, so change it before serving the files, or your client will store secrets on the hosted API.
Going beyond localhost
HTTPS is required
The web client encrypts with the Web Crypto API, which browsers expose only on HTTPS pages. http://localhost is the one exception. To use your instance from another machine, serve both the web client and the API over HTTPS, for example behind a TLS-terminating reverse proxy, and set API_URL to the API's https:// address. An HTTPS page cannot call an http:// API. This guide does not include a tested reverse proxy configuration.
Abuse protection
Anyone who can reach the API can write to it unless you say otherwise. The service has these controls:
- Write rate limit.
WRITE_REQUESTS_PER_MINUTEapplies per client IP. Invalid requests count too. When it is exhausted the API answers429with aRetry-Afterheader in seconds. Reads are not limited. - Storage caps.
STORAGE_MAX_BYTESandSTORAGE_MAX_KEYSare checked before every write. When either is reached the API answers503until records are retrieved or expire. - Write token. Set
WRITE_API_TOKENand writers must sendAuthorization: Bearer <token>. Without it the API answers401. Reading stays public, so recipients need no token. The CLI and the self-hosted web client cannot send this token today, so with it set only your own clients can create shares. - Client IP behind a proxy. By default the service uses the socket address and ignores forwarded headers, so every visitor behind a reverse proxy shares one write allowance. Set
TRUSTED_PROXY_IPSto the proxy's address and have the proxy overwriteX-Real-IPwith the verified client IP. Keep direct access to the API restricted. - Redis memory policy. The service never changes Redis settings. Its example
redis.confsetsmaxmemory 256mb,maxmemory-policy volatile-ttland no persistence, with a 384 MiB container limit. Under memory pressurevolatile-ttlcan evict a share before its expiry. If records must survive until they expire, usenoevictionand accept rejected writes instead. Redis Cluster is not supported.
The API answers cross-origin requests from any origin. The abuse protection and rollout section of the service README has the full details, including the Redis permissions the service needs.
Optional sender notifications
The API can email the sender when a secret or file is retrieved. A client asks for this by sending senderEmail when it creates a share. To turn the feature on, set SMTP_HOST and SMTP_FROM, and optionally SMTP_PORT (default 587). Authentication uses SMTP_USERNAME and SMTP_PASSWORD together. STARTTLS is required and the server certificate is verified. A partial or invalid configuration stops the service from starting.
- Without SMTP configured, a request that includes
senderEmailis refused with503. Requests without it work as usual. - The email contains the ID, plus the IP address and user agent of whoever retrieved it. It never contains ciphertext, keys, metadata or a link.
- Delivery is one best-effort attempt. There is no retry, so a mail server failure loses the notification.
- Neither the CLI nor the self-hosted web client sends
senderEmail. Only a client you write can use this.
Using the CLI with your instance
Every SnapPwd CLI command accepts --api-url. The value is your API's base URL including /v1:
npx @snappwd/cli put "Internal secret" --api-url http://localhost:8080/v1
npx @snappwd/cli put-file ./database.env --api-url http://localhost:8080/v1
npx @snappwd/cli peek "<url>" --api-url http://localhost:8080/v1
npx @snappwd/cli get "<url>" --api-url http://localhost:8080/v1The link the CLI prints for a self-hosted API, such as http://localhost:8080/v1/g/sps-...#..., is built from the API URL. It is not a page you can open in a browser. Pass it back to get or peek with the same --api-url.
Known limitations
- The self-hosted web client and the CLI cannot open each other's shares. They use different link and ciphertext formats. A CLI link has the form
/g/<id>#<key>, while the web client only reads?id=<id>#key=<key>, so opening a CLI link as it is does nothing. Do not rewrite a CLI share into the web client's link format: when you then choose to reveal it, the web client fetches the record, which deletes it, before decryption fails, and the share is lost. The CLI rejects a web client link without contacting the API. Read each share with the client that created it. - The self-hosted web client is smaller than the hosted app. It shares text only, with a choice of 1 hour, 1 day or 1 week. It has no file sharing, no custom expiry, no option to send the key separately and no reveal notifications, all of which the hosted app has. The API and the CLI do support files.
- A write token locks out the stock clients. With
WRITE_API_TOKENset, the CLI and the web client cannot create shares, because neither can send the token. - Metadata is not encrypted. See what the server stores.
- One-time means one retrieval, not one reader. Whoever fetches a share first gets it. Anyone holding the complete link, including the part after
#, can decrypt it.
Found something that does not match this page? Open an issue on the snappwd-service or snappwd-web repository.