diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
deleted file mode 100644
index bb987e1..0000000
--- a/DEPLOYMENT.md
+++ /dev/null
@@ -1,141 +0,0 @@
-# Production migration and rollback
-
-This runbook moves the titan deployment from the Python source tree at
-`/root/repo/urlshortener` to the published `sodium/ushort` image. The final
-server-side application directory is `/root/compose/ushort`; no source checkout
-is required on titan.
-
-Do not cut over until the exact image tag has been built for both `linux/amd64`
-and `linux/arm64`, pushed, and recorded by digest.
-
-## 1. Prepare without affecting production
-
-Create this layout on titan:
-
-```text
-/root/compose/ushort/
-├── docker-compose.yml
-├── config.toml
-└── data/
-```
-
-Copy `deploy/compose.production.yml` to the server as `docker-compose.yml`.
-Convert the live JSON
-settings to TOML using `config.example.toml` as the reference, with these
-cutover-specific rules:
-
-- keep the live `base_url`, limits, retention, and rate-limit values;
-- set `db_path = "data/urlshort.db"`;
-- set `production = false` so the binary serves its embedded frontend; and
-- generate a new API key, because the old key existed in the legacy Git
- history.
-
-Keep `config.toml` owned by `1001:1001` with mode `0400`, matching the
-container identity. Do not copy it into Git or a container image.
-
-Validate the deployment file before stopping anything:
-
-```bash
-cd /root/compose/ushort
-chown 1001:1001 config.toml
-chmod 0400 config.toml
-docker-compose config
-docker pull sodium/ushort:0.1.1
-```
-
-## 2. Take a consistent database copy
-
-The final copy must be made while the Python service is stopped so no committed
-row is missed and no SQLite journal is in flight:
-
-```bash
-cd /root/repo/urlshortener
-docker-compose stop urlshort
-
-cp -a data/urlshort.db /root/compose/ushort/data/urlshort.db
-cp -a data/urlshort.db /root/compose/ushort/data/urlshort.db.pre-rust
-chown -R 1001:1001 /root/compose/ushort/data
-chmod 0750 /root/compose/ushort/data
-chmod 0640 /root/compose/ushort/data/urlshort.db*
-```
-
-Do not delete or alter the original database during the initial soak period.
-
-## 3. Start and validate ushort locally
-
-```bash
-cd /root/compose/ushort
-docker-compose up -d
-docker-compose ps
-docker-compose logs --tail=100 ushort
-
-curl -i http://127.0.0.1:18082/s/api/health
-curl -I http://127.0.0.1:18082/s/
-```
-
-Expected results are HTTP 200 health JSON and HTTP 200 HTML. Confirm the
-container does not restart and the database remains writable.
-
-## 4. Switch nginx
-
-Replace the old `include /root/repo/urlshortener/nginx.conf;` in the `xcel.me`
-vhost with the location blocks from the new `nginx.conf`. Those blocks proxy
-the frontend as well as API/redirect traffic to `127.0.0.1:18082`; they do not
-refer to a source or static-file directory.
-
-Test before reload:
-
-```bash
-docker exec nginx nginx -t
-docker exec nginx nginx -s reload
-```
-
-Then validate through the public endpoint:
-
-```bash
-curl -i https://xcel.me/s/api/health
-curl -I https://xcel.me/s/
-curl -I https://xcel.me/s/static/app.js
-```
-
-Check that `/s/` is `no-store`, static assets are `no-cache`, an existing short
-code still returns the original 302 target, and its visit count increments once.
-
-## 5. Soak and clean up
-
-During the soak period, monitor:
-
-```bash
-cd /root/compose/ushort
-docker-compose ps
-docker-compose logs --tail=200 ushort
-```
-
-After the rollback window closes:
-
-1. retain a protected database backup;
-2. remove the obsolete `/root/repo/urlshortener` source tree;
-3. remove its `/srv/urlshortener` bind mount from the global nginx Compose
- file; and
-4. recreate nginx and re-run `nginx -t`.
-
-At that point, application source exists only in Gitea, while titan contains
-only the Compose file, TOML config, and data under `/root/compose/ushort`.
-
-## Rollback
-
-If validation fails before public traffic is enabled:
-
-```bash
-cd /root/compose/ushort
-docker-compose down
-cd /root/repo/urlshortener
-docker-compose start urlshort
-```
-
-Restore the old nginx include and reload nginx.
-
-If public writes occurred after cutover, stop ushort first and copy its current
-database back to the legacy data path before starting Python; otherwise those
-new short URLs would be lost. The schema is deliberately identical, so no
-reverse schema migration is required.
diff --git a/README.md b/README.md
index 474e09e..13975dc 100644
--- a/README.md
+++ b/README.md
@@ -1,234 +1,46 @@
# ushort
-`ushort` is a compact URL shortener written in Rust and backed by SQLite. It is
-a behavior-compatible replacement for the original stdlib-only Python service:
-the routes, base-path handling, response shapes, database schema, retention,
-API-key checks, throttling, redirects, and frontend are preserved.
+`ushort` is a compact, self-contained URL shortener written in Rust and backed
+by SQLite. It serves an embedded web interface and JSON API from a single
+statically linked executable.
-The frontend under `static/` is embedded into the executable at compile time.
-The final Linux image contains only one statically linked executable and runs
-without Python, a shell, a package manager, or external static files.
+It supports deployment at either a dedicated host such as `s.example.com` or
+under a path such as `example.com/s`. Images are available for Linux AMD64 and
+ARM64.
## Quick start
+Requirements: Docker with the Compose plugin.
+
```bash
cp config.example.toml config.toml
-# Edit base_url and api_key, then:
-docker compose up --build -d
+# Set a private api_key in config.toml.
+
+mkdir -p data
+USHORT_UID="$(id -u)" USHORT_GID="$(id -g)" \
+ docker compose -f deploy/docker-compose.build.yml up --build -d
```
-The root `docker-compose.yml` is the local-development definition and builds
-the checked-out source. `deploy/compose.production.yml` is the source-free
-server definition and pulls a versioned image; copy it to the deployment host
-as `docker-compose.yml`.
+The example starts ushort at and stores its SQLite
+database in `./data/urlshort.db`.
-The service listens on `127.0.0.1:18082` on the host. Its SQLite database is
-stored at `./data/urlshort.db` and survives container replacement.
-
-The container runs as UID/GID `1001:1001` by default. Override
-`USHORT_UID`/`USHORT_GID` if needed, and ensure `./data` is writable by that
-identity.
-
-## Configuration
-
-TOML is the preferred format; see `config.example.toml`. Existing JSON files
-remain supported, including the old `short_length` alias for
-`min_short_length`.
+Check it and stop it with:
```bash
-ushort config.toml
-ushort legacy-config.json
+curl http://localhost:18082/s/api/health
+docker compose -f deploy/docker-compose.build.yml down
```
-| Key | Required | Default | Description |
-|---|---:|---:|---|
-| `base_url` | yes | — | Public URL. Its path becomes the routing prefix. |
-| `api_key` | yes | — | Secret used by list and delete operations. |
-| `host` | no | `0.0.0.0` | Bind address. |
-| `port` | no | `8080` | Bind port. |
-| `db_path` | no | `data/urlshort.db` | SQLite database path. |
-| `retention_days` | no | `0` | Default lifetime for new URLs; zero never expires. |
-| `min_short_length` | no | `6` | Initial generated code length. |
-| `max_short_length` | no | `32` | Maximum generated and accepted code length. |
-| `max_url_length` | no | `2048` | Maximum original URL length. |
-| `max_retention_days` | no | `3650` | Maximum per-URL retention value. |
-| `rate_limit_requests` | no | `60` | Requests allowed per client/window. |
-| `rate_limit_window` | no | `60` | Sliding-window length in seconds. |
-| `production` | no | `false` | When true, disable embedded frontend routes for compatibility with deployments that serve static files separately. |
+For a production server, use the published image through
+`deploy/docker-compose.prod.yml`; it does not build or require a source
+checkout.
-The same binary supports both common reverse-proxy layouts:
+## Documentation
-- `base_url = "https://s.example.com"` serves the frontend, API, and short
- codes at the subdomain root, producing `https://s.example.com/aB3xYz`.
-- `base_url = "https://example.com/go/links"` limits routing to `/go/links`
- and produces `https://example.com/go/links/aB3xYz`. Requests outside that
- prefix return 404.
-
-A trailing slash on `base_url` is accepted in either layout and is normalized
-when short URLs are generated.
-
-Real API keys belong only in the deployment's ignored `config.toml`, never in
-the repository or container image.
-
-## API
-
-All paths below are relative to the path in `base_url`. Trailing slashes are
-accepted by every frontend, API, metadata, deletion, and short-code route,
-including when a query string follows the slash. Every response includes the
-legacy CORS and security headers.
-
-### Health and frontend
-
-- `GET /` serves the byte-identical embedded frontend when `production=false`.
-- `GET /api/health` returns HTTP 200:
-
-```json
-{"status":"ok","service":"url-shortener"}
-```
-
-When the configured base path is non-empty, a GET of the bare path (for
-example `/s`) redirects to `/s/`.
-
-### Create a short URL
-
-`POST /api/shorten` and `GET /api/shorten` are both supported and do not
-require an API key. Fields may be supplied as query parameters or in a JSON
-body; query parameters take precedence.
-
-| Field | Required | Description |
-|---|---:|---|
-| `url` | yes | Absolute `http://` or `https://` URL. |
-| `retention_days` | no | Per-URL lifetime, or zero to keep forever. |
-
-Success is HTTP 201 with only the short URL as UTF-8 plain text:
-
-```text
-https://example.com/s/aB3xYz
-```
-
-Codes begin at `min_short_length`. After ten collisions at one length the
-service tries the next length, through `max_short_length`.
-
-### List URLs
-
-`GET /api/urls?api_key=` returns all rows newest first. A missing or
-invalid key returns HTTP 403 with an empty body.
-
-```json
-{
- "count": 1,
- "urls": [
- {
- "short_code": "aB3xYz",
- "short_url": "https://example.com/s/aB3xYz",
- "original_url": "https://example.org",
- "created_at": 1710000000,
- "visit_count": 5,
- "retention_days": 30
- }
- ]
-}
-```
-
-### Metadata and lookup
-
-- `GET /api/urls/` returns one metadata object without authentication.
-- `GET /api/lookup?url=` returns the newest matching metadata
- object without authentication.
-- A missing record returns HTTP 404 with an empty body.
-
-### Delete
-
-`DELETE /api/urls/?api_key=` returns:
-
-- HTTP 204 with an empty body on success;
-- HTTP 404 with an empty body when the code does not exist; or
-- HTTP 403 with an empty body when authentication fails.
-
-### Redirect
-
-`GET /` atomically increments `visit_count` and responds with HTTP 302 to
-the stored original URL.
-
-## Data compatibility
-
-The existing database can be mounted directly; no export/import is required.
-The schema remains:
-
-```sql
-CREATE TABLE urls (
- short_code TEXT PRIMARY KEY,
- original_url TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- visit_count INTEGER NOT NULL DEFAULT 0,
- retention_days INTEGER NOT NULL DEFAULT 0
-);
-```
-
-Older databases missing `retention_days` are upgraded in place with a default
-of zero. Expired records are removed on startup and before each GET, POST, or
-DELETE request, using the original strict expiry boundary.
-
-Before a production cutover, make a filesystem-consistent backup of the
-database (or use SQLite's backup command) and test the copy with the new image.
-
-## Frontend
-
-The existing `index.html`, JavaScript, CSS, locally hosted fonts, sorting,
-pagination, theme selection, URL lookup, copy/delete actions, and admin flow
-are unchanged. Compile-time embedding makes those files part of the standalone
-artifact; no bind mount or separate web root is needed.
-
-For a path deployment such as the existing `/s`, place the location blocks
-from `nginx.conf` in the public vhost. For a dedicated subdomain, use
-`deploy/nginx-subdomain.conf` in its `server` block. Both examples proxy the
-frontend, API, and redirects while retaining `no-store` HTML and `no-cache`
-asset policies.
-
-## Build and test
-
-The repository intentionally does not require a host Rust installation.
-
-```bash
-docker build -t ushort:local .
-docker compose up -d
-```
-
-Run the Rust test suite in an isolated build stage:
-
-```bash
-docker build --target tester .
-```
-
-Run the real HTTP trailing-slash smoke test against any disposable deployment:
-
-```bash
-sh tests/smoke.sh https://s.example.com "$API_KEY"
-sh tests/smoke.sh https://example.com/s "$API_KEY"
-```
-
-The Dockerfile builds against musl and uses `scratch` for the runtime image.
-Docker Buildx can publish both required architectures from the same source:
-
-```bash
-docker buildx build \
- --platform linux/amd64,linux/arm64 \
- -t sodium/ushort:latest \
- --push .
-```
-
-## Security model
-
-- SQL statements use bound parameters.
-- Short codes are restricted to ASCII alphanumerics.
-- URL, retention, code, and request-body sizes are bounded.
-- API-key comparison is constant-time; protected operations return no body on
- authentication failure.
-- Per-IP sliding-window throttling applies to API calls and redirects; embedded
- frontend files do not consume API quota. The app uses `X-Real-IP`, then the
- first `X-Forwarded-For` value, then the TCP peer. Keep the container port
- bound to loopback and let the trusted reverse proxy overwrite those headers.
-- CORS preflight uses `OPTIONS`; it does not consume rate-limit quota.
-- The runtime image is read-only, drops Linux capabilities, and runs as a
- numeric non-root user through Compose.
-- `SIGINT`/`SIGTERM` cleanly unblock the server for prompt container shutdown.
+- [Configuration](docs/configuration.md)
+- [API reference](docs/api.md)
+- [Embedded frontend](docs/frontend.md)
+- [Data and database compatibility](docs/data.md)
+- [Production deployment and rollback](docs/deployment.md)
+- [Development, testing, and releases](docs/development.md)
+- [Security model](docs/security.md)
diff --git a/config.example.toml b/config.example.toml
index 7c85ffc..a5049b7 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -1,5 +1,5 @@
# Public URL, including the optional routing prefix.
-base_url = "http://localhost:8080/s"
+base_url = "http://localhost:18082/s"
# Replace this in the deployment-only config.toml. Do not commit real secrets.
api_key = "change-this-secret-key"
diff --git a/docker-compose.yml b/deploy/docker-compose.build.yml
similarity index 68%
rename from docker-compose.yml
rename to deploy/docker-compose.build.yml
index 9ea4029..410f692 100644
--- a/docker-compose.yml
+++ b/deploy/docker-compose.build.yml
@@ -1,14 +1,14 @@
services:
ushort:
- image: sodium/ushort:latest
+ image: ushort:local
build:
- context: .
+ context: ..
command: ["/app/config.toml"]
ports:
- "127.0.0.1:18082:8080"
volumes:
- - "${USHORT_CONFIG:-./config.toml}:/app/config.toml:ro"
- - ./data:/app/data
+ - "${USHORT_CONFIG:-../config.toml}:/app/config.toml:ro"
+ - ../data:/app/data
user: "${USHORT_UID:-1001}:${USHORT_GID:-1001}"
read_only: true
security_opt:
@@ -16,4 +16,3 @@ services:
cap_drop:
- ALL
restart: unless-stopped
-
diff --git a/deploy/compose.production.yml b/deploy/docker-compose.prod.yml
similarity index 100%
rename from deploy/compose.production.yml
rename to deploy/docker-compose.prod.yml
diff --git a/nginx.conf b/deploy/nginx.path.conf
similarity index 78%
rename from nginx.conf
rename to deploy/nginx.path.conf
index a348c42..b2b59c9 100644
--- a/nginx.conf
+++ b/deploy/nginx.path.conf
@@ -1,12 +1,11 @@
-# URL Shortener — nginx location blocks
-# Drop these into the existing server {} block. The Rust binary serves both
-# the API and its embedded, byte-identical frontend on 127.0.0.1:18082.
+# URL Shortener — path-prefix deployment
+# Use inside a server {} block with base_url configured for /s.
location = /s {
return 301 /s/;
}
-# Preserve the existing no-store policy for the HTML entry point.
+# HTML entry point.
location = /s/ {
proxy_pass http://127.0.0.1:18082;
proxy_http_version 1.1;
@@ -20,7 +19,7 @@ location = /s/ {
add_header Cache-Control "no-store";
}
-# Preserve the existing revalidation policy for CSS, JavaScript, and fonts.
+# CSS, JavaScript, and fonts.
location ^~ /s/static/ {
proxy_pass http://127.0.0.1:18082;
proxy_http_version 1.1;
@@ -44,4 +43,3 @@ location /s/ {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
-
diff --git a/deploy/nginx-subdomain.conf b/deploy/nginx.subdomain.conf
similarity index 79%
rename from deploy/nginx-subdomain.conf
rename to deploy/nginx.subdomain.conf
index 0057d7d..3dd3e3f 100644
--- a/deploy/nginx-subdomain.conf
+++ b/deploy/nginx.subdomain.conf
@@ -1,8 +1,7 @@
-# URL Shortener — dedicated subdomain location blocks
-# Use inside the server {} block for a host such as s.example.com. Configure
-# base_url = "https://s.example.com" in config.toml.
+# URL Shortener — dedicated-subdomain deployment
+# Use inside a server {} block with base_url configured for that host.
-# Preserve the no-store policy for the HTML entry point.
+# HTML entry point.
location = / {
proxy_pass http://127.0.0.1:18082;
proxy_http_version 1.1;
@@ -16,7 +15,7 @@ location = / {
add_header Cache-Control "no-store";
}
-# Preserve the revalidation policy for CSS, JavaScript, and fonts.
+# CSS, JavaScript, and fonts.
location ^~ /static/ {
proxy_pass http://127.0.0.1:18082;
proxy_http_version 1.1;
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..7f10ed2
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,80 @@
+# API reference
+
+All paths are relative to the path in `base_url`. Every frontend, API,
+metadata, deletion, and short-code route accepts trailing slashes, including
+when a query string follows the slash.
+
+## Health and frontend
+
+- `GET /` serves the embedded frontend when `production=false`.
+- `GET /api/health` returns HTTP 200:
+
+```json
+{"status": "ok", "service": "url-shortener"}
+```
+
+With a non-empty base path, requesting the bare path redirects to its trailing
+slash form; for example, `/s` redirects to `/s/`.
+
+## Create a short URL
+
+`POST /api/shorten` and `GET /api/shorten` are supported without an API key.
+Fields may be supplied as query parameters or in a JSON body; query parameters
+take precedence.
+
+| Field | Required | Description |
+|---|---:|---|
+| `url` | yes | Absolute `http://` or `https://` URL. |
+| `retention_days` | no | Per-URL lifetime, or zero to keep forever. |
+
+Success is HTTP 201 with only the short URL as UTF-8 plain text:
+
+```text
+https://example.com/s/aB3xYz
+```
+
+Codes begin at `min_short_length`. After ten collisions at one length, ushort
+tries the next length through `max_short_length`.
+
+## List URLs
+
+`GET /api/urls?api_key=` returns all rows newest first. A missing or
+invalid key returns HTTP 403 with an empty body.
+
+```json
+{
+ "count": 1,
+ "urls": [
+ {
+ "short_code": "aB3xYz",
+ "short_url": "https://example.com/s/aB3xYz",
+ "original_url": "https://example.org",
+ "created_at": 1710000000,
+ "visit_count": 5,
+ "retention_days": 30
+ }
+ ]
+}
+```
+
+## Metadata and lookup
+
+- `GET /api/urls/` returns one metadata object without authentication.
+- `GET /api/lookup?url=` returns the newest matching metadata
+ object without authentication.
+- A missing record returns HTTP 404 with an empty body.
+
+## Delete
+
+`DELETE /api/urls/?api_key=` returns:
+
+- HTTP 204 with an empty body on success;
+- HTTP 404 with an empty body when the code does not exist; or
+- HTTP 403 with an empty body when authentication fails.
+
+## Redirect
+
+`GET /` atomically increments `visit_count` and responds with HTTP 302 to
+the stored original URL.
+
+Every response includes the compatibility CORS and security headers.
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..0946d77
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,44 @@
+# Configuration
+
+ushort accepts TOML and legacy JSON configuration files. TOML is preferred;
+[`config.example.toml`](../config.example.toml) is the maintained reference.
+
+```bash
+ushort config.toml
+ushort legacy-config.json
+```
+
+## Options
+
+| Key | Required | Default | Description |
+|---|---:|---:|---|
+| `base_url` | yes | — | Public URL. Its path becomes the routing prefix. |
+| `api_key` | yes | — | Secret used by list and delete operations. |
+| `host` | no | `0.0.0.0` | Bind address. |
+| `port` | no | `8080` | Bind port. |
+| `db_path` | no | `data/urlshort.db` | SQLite database path. |
+| `retention_days` | no | `0` | Default lifetime for new URLs; zero never expires. |
+| `min_short_length` | no | `6` | Initial generated code length. |
+| `max_short_length` | no | `32` | Maximum generated and accepted code length. |
+| `max_url_length` | no | `2048` | Maximum original URL length. |
+| `max_retention_days` | no | `3650` | Maximum per-URL retention value. |
+| `rate_limit_requests` | no | `60` | Requests allowed per client/window. |
+| `rate_limit_window` | no | `60` | Sliding-window length in seconds. |
+| `production` | no | `false` | Disable embedded frontend routes when a separate server provides them. |
+
+The legacy `short_length` option remains an alias for `min_short_length`.
+
+## Public URL layouts
+
+The same binary supports both common reverse-proxy layouts:
+
+- `base_url = "https://s.example.com"` serves everything at the subdomain
+ root and produces URLs such as `https://s.example.com/aB3xYz`.
+- `base_url = "https://example.com/go/links"` routes only under `/go/links`
+ and produces `https://example.com/go/links/aB3xYz`.
+
+A trailing slash on `base_url` is accepted and normalized. Requests outside a
+configured non-empty path prefix return 404.
+
+Keep real API keys only in the ignored deployment `config.toml`. Never commit
+them or include them in a container image.
diff --git a/docs/data.md b/docs/data.md
new file mode 100644
index 0000000..c04e3af
--- /dev/null
+++ b/docs/data.md
@@ -0,0 +1,31 @@
+# Data and database compatibility
+
+ushort stores data in SQLite. The database can be mounted directly across
+upgrades; no export or import is required.
+
+```sql
+CREATE TABLE urls (
+ short_code TEXT PRIMARY KEY,
+ original_url TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ visit_count INTEGER NOT NULL DEFAULT 0,
+ retention_days INTEGER NOT NULL DEFAULT 0
+);
+```
+
+Older databases missing `retention_days` are upgraded in place with a default
+of zero. Expired records are removed on startup and before each GET, POST, or
+DELETE request, using a strict expiry boundary.
+
+## Backups
+
+Take a consistent backup before every production update. SQLite's online
+backup command avoids copying a database while a WAL transaction is active:
+
+```bash
+sqlite3 data/urlshort.db ".backup data/urlshort.db.pre-update"
+sqlite3 data/urlshort.db.pre-update "PRAGMA integrity_check;"
+```
+
+Retain the backup until the updated release has passed health, redirect,
+write, and database-integrity checks.
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..86599c5
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,90 @@
+# Production deployment and rollback
+
+The production host needs only a Compose file, `config.toml`, and persistent
+data. It does not need a source checkout.
+
+## Files
+
+Create an application directory like this:
+
+```text
+/opt/ushort/
+├── docker-compose.yml
+├── config.toml
+└── data/
+```
+
+Copy [`deploy/docker-compose.prod.yml`](../deploy/docker-compose.prod.yml) to
+the server as `docker-compose.yml`. For repeatable releases, replace its image
+tag with the verified multi-architecture digest:
+
+```yaml
+image: docker.io/sodium/ushort:0.1.1@sha256:
+```
+
+Set `db_path = "data/urlshort.db"` and normally keep `production = false` so
+the executable serves its embedded frontend. Keep `config.toml` owned by the
+container identity (`1001:1001`) with mode `0400`; the `data` directory must be
+writable by the same identity.
+
+## Start and validate
+
+```bash
+cd /opt/ushort
+docker compose config
+docker compose pull
+docker compose up -d
+docker compose ps
+docker compose logs --tail=100 ushort
+
+curl -i http://127.0.0.1:18082/s/api/health
+```
+
+Adjust the health path to match `base_url`.
+
+## Reverse proxy
+
+Choose one nginx snippet and include it inside the public `server {}` block:
+
+- [`deploy/nginx.path.conf`](../deploy/nginx.path.conf) for a path such as
+ `https://example.com/s`;
+- [`deploy/nginx.subdomain.conf`](../deploy/nginx.subdomain.conf) for a host
+ such as `https://s.example.com`.
+
+Both snippets proxy the frontend, API, and redirects to `127.0.0.1:18082`.
+They apply `no-store` to the HTML entry point and `no-cache` to static assets.
+The path snippet is written for `/s`; replace each `/s` location when using a
+different configured prefix.
+
+Test nginx before reloading it:
+
+```bash
+nginx -t
+nginx -s reload
+```
+
+Then verify the public health endpoint, frontend, static assets, and an
+existing short-code redirect. [`tests/smoke.sh`](../tests/smoke.sh) exercises
+the complete trailing-slash route set against a disposable record.
+
+## Update
+
+1. Verify the published image contains both AMD64 and ARM64 manifests.
+2. Pull the exact version and digest before changing the running service.
+3. Create and integrity-check an online SQLite backup as described in
+ [Data and database compatibility](data.md).
+4. Update the image reference and run `docker compose up -d`.
+5. Check health, logs, restart count, database integrity, and public routes.
+
+## Rollback
+
+Restore the previous image reference and run:
+
+```bash
+docker compose up -d
+```
+
+The schema is release-compatible. If the failed release changed or damaged
+data, stop the service and restore the pre-update SQLite backup before bringing
+the previous image back up. Preserve any legitimate writes made after the
+backup before replacing the database.
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..93bf8f2
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,58 @@
+# Development, testing, and releases
+
+The repository does not require a host Rust installation. The Dockerfile uses
+musl and produces a `scratch` runtime image containing only the executable.
+
+## Local source build
+
+[`deploy/docker-compose.build.yml`](../deploy/docker-compose.build.yml) builds
+the checked-out source. Its build context and bind mounts point back to the
+repository root.
+
+```bash
+cp config.example.toml config.toml
+mkdir -p data
+USHORT_UID="$(id -u)" USHORT_GID="$(id -g)" \
+ docker compose -f deploy/docker-compose.build.yml up --build -d
+```
+
+The production definition,
+[`deploy/docker-compose.prod.yml`](../deploy/docker-compose.prod.yml), never
+builds source. It pulls the published version instead.
+
+## Automated tests
+
+Run all Rust targets in the isolated tester stage:
+
+```bash
+docker build --target tester .
+```
+
+Run the real HTTP trailing-slash suite against a disposable deployment:
+
+```bash
+sh tests/smoke.sh https://s.example.com "$API_KEY"
+sh tests/smoke.sh https://example.com/s "$API_KEY"
+```
+
+The smoke test creates and removes one temporary short URL.
+
+## Multi-architecture release
+
+Buildx can publish AMD64 and ARM64 from the same source:
+
+```bash
+docker buildx build \
+ --platform linux/amd64,linux/arm64 \
+ -t sodium/ushort:latest \
+ --push .
+```
+
+Inspect and record the OCI index digest before deployment:
+
+```bash
+docker buildx imagetools inspect sodium/ushort:latest
+```
+
+The embedded frontend test verifies every file under `static/` byte-for-byte,
+so no separate web-root packaging step is required.
diff --git a/docs/frontend.md b/docs/frontend.md
new file mode 100644
index 0000000..bb1e7f3
--- /dev/null
+++ b/docs/frontend.md
@@ -0,0 +1,20 @@
+# Embedded frontend
+
+The frontend under `static/` is embedded into the executable at compile time.
+No separate web root or static-file bind mount is required.
+
+It includes URL creation and lookup, short-link metadata, copy and delete
+actions, API-key administration, sorting, pagination, theme selection, and
+locally hosted fonts. Relative asset URLs and the browser-derived API prefix
+allow the same files to work at either a subdomain root or a nested path.
+
+Set `production = false` for the normal self-contained deployment. Setting it
+to `true` disables the embedded frontend routes for installations that serve
+those files separately.
+
+The nginx examples apply `no-store` to the HTML entry point and `no-cache` to
+static assets. Embedded static requests do not consume the API rate-limit
+quota.
+
+Automated tests compare every embedded asset with its source file byte for
+byte. See [Development, testing, and releases](development.md).
diff --git a/docs/security.md b/docs/security.md
new file mode 100644
index 0000000..965253b
--- /dev/null
+++ b/docs/security.md
@@ -0,0 +1,22 @@
+# Security model
+
+- SQL statements use bound parameters.
+- Short codes are restricted to ASCII alphanumerics.
+- URL, retention, code, and request-body sizes are bounded.
+- API-key comparison is constant-time. Protected operations return no body on
+ authentication failure.
+- Per-IP sliding-window throttling applies to API calls and redirects. Embedded
+ frontend files do not consume API quota.
+- Client identity uses `X-Real-IP`, then the first `X-Forwarded-For` value,
+ then the TCP peer. Bind the container port to loopback and let only a trusted
+ reverse proxy overwrite those headers.
+- CORS preflight uses `OPTIONS` and does not consume rate-limit quota.
+- The runtime Compose definitions use a read-only root filesystem, drop Linux
+ capabilities, enable `no-new-privileges`, and run as a numeric non-root user.
+- The runtime image contains no shell, package manager, Python runtime, or
+ external static files.
+- `SIGINT` and `SIGTERM` cleanly unblock the server for prompt shutdown.
+
+Keep `api_key` out of Git, image layers, command output, and monitoring labels.
+The supplied nginx configurations forward trusted proxy headers and keep the
+application port bound to `127.0.0.1`.