Operations, admin commands and backups
Data on disk
Section titled “Data on disk”Everything lives under DATA_DIR (./data by default, /data in the container image):
| Path | Content |
|---|---|
links.sqlite |
Short links. WAL mode, synchronous = NORMAL. |
images.sqlite |
Images and the SHA-256 of their delete tokens. Same settings. |
backups/links-YYYY-MM-DD.sqlite |
Daily copies of the links database. |
backups/images-YYYY-MM-DD.sqlite |
Daily copies of the images database. |
Each store has its own file so images never weigh on the links’ database or its backups.
Backups and the expiry sweep
Section titled “Backups and the expiry sweep”At start, and then every BACKUP_INTERVAL_MS (one hour by default), the server runs a maintenance step:
- If
backups/links-<today>.sqlitedoes not exist (today in UTC), it runsVACUUM INTOto a temporary file and renames it into place. It then keeps the newestBACKUP_KEEP(7) daily link backups and deletes older ones. - The same for images, keeping
IMAGE_BACKUP_KEEP(2). - It deletes expired links.
A backup that is created or a file that is removed produces a log line, for example {"event":"backup","file":"links-2026-10-24.sqlite","created":true,"removed":0}. A failure is logged with "level":"error" and does not stop the service.
Backup files are ordinary SQLite databases. They sit inside the data volume, next to the live databases.
BACKUP_INTERVAL_MS=0 stops the periodic run. The maintenance step at start still runs once.
The admin command
Section titled “The admin command”src/admin.mjs works on the databases of a running service and prints one JSON line. It never prints payloads or image bytes. It reads DATA_DIR and the store caps from the environment, so run it with the same environment as the service.
node src/admin.mjs statsnode src/admin.mjs sweepnode src/admin.mjs purge --from 2026-10-01T00:00:00Z --to 2026-10-02T00:00:00Z --unopened [--yes]node src/admin.mjs image-statsnode src/admin.mjs image-purge --id Jd83kQm0aZ [--yes]node src/admin.mjs image-purge --from 2026-10-01T00:00:00Z --to 2026-10-02T00:00:00Z [--yes]In the container:
docker compose exec share-links node --disable-warning=ExperimentalWarning src/admin.mjs statsCommands and output
Section titled “Commands and output”| Command | Output fields |
|---|---|
stats |
count, bytes, unopened, unopenedBytes, oldest, newest, level, maxLinks, maxLinkBytes |
sweep |
deleted: expired links removed now |
purge --from --to [--unopened] [--yes] |
from, to, unopened, dryRun, count, bytes, deleted |
image-stats |
count, bytes, oldest, newest, level, maxImages, maxImageBytes |
image-purge --id <id> [--yes] |
id, found, dryRun, deleted |
image-purge --from --to [--yes] |
from, to, dryRun, count, bytes, deleted |
oldest and newest are creation times. unopened counts links whose /s/ URL nobody has opened yet.
Rules for arguments
Section titled “Rules for arguments”purgeandimage-purgeonly count until--yesis given. Without it,dryRunistrueanddeletedis0.--fromand--toselect rows created in[from, to). Each takes a date (2026-10-01) or a UTC time (2026-10-01T10:30:00Z, seconds and milliseconds optional).frommust be earlier thanto.purgewithout--unopeneddeletes opened links in the window too.image-purge --iddeletes an image whoever uploaded it, without its token. It takes either--idor a--from/--topair, not both.- Each command accepts only its own options.
stats --yesorimage-purge --id ... --unopenedfails instead of ignoring the extra option. - Invalid arguments print the usage line to stderr and exit with code 1.
Example: count and then delete links created during one day that nobody opened.
node src/admin.mjs purge --from 2026-10-01 --to 2026-10-02 --unopened# {"from":"2026-10-01T00:00:00.000Z","to":"2026-10-02T00:00:00.000Z","unopened":true,"dryRun":true,"count":<n>,"bytes":<n>,"deleted":0}node src/admin.mjs purge --from 2026-10-01 --to 2026-10-02 --unopened --yesLinks have no delete route in the HTTP API. purge and sweep are the only ways to remove them before they expire.
Health checks
Section titled “Health checks”GET /api/health (or HEAD) answers 200 {"status":"ok","links":"<level>","images":"<level>"} while both databases answer a ping. A ping with an unexpected result answers 503 {"error":"unavailable"}. A database error answers 500 {"error":"internal"}. The endpoint is not rate limited. The container’s HEALTHCHECK calls it every 30 seconds.
A high or full level is not an error for the health check: it still answers 200. Watch the level fields, or the storage log events, to see a store filling up.
The service writes one JSON object per line to stdout, each with a time field.
| Kind | Fields |
|---|---|
| Request | method, route, status, ms |
listening |
port |
storage |
store, usage (ok, high, full), and level: "warning" unless usage is ok |
backup, image-backup |
file, created, removed |
expired |
removed: links deleted by the sweep |
| Error | level: "error", then route and the error’s class name for a request, or event and the message for maintenance |
Request lines never contain ids, payloads, addresses or tokens. The compose file rotates container logs at 10 MB with three files.
Shutdown
Section titled “Shutdown”SIGTERM and SIGINT stop the backup timer, close the HTTP server, close both databases and exit with code 0. If the server has not closed after 5 seconds, the process exits anyway.
Written from the code and tests of each repository. If a page and the code disagree, the code is right and the page has a bug. Open an issue.
© 2026 Jean Carlo Benites Porras. Code samples are MIT, like the projects they come from.