Running Nitro Photos
Architecture
Nitro Photos is three layers, and every layer is something you run or hold, not something we do:
Every client talks HTTP to the one server you point it at. The server holds one admin account, the device registry, and the library — there's no Nitro-operated service in the middle.
Running the server
Docker is the fastest path:
Shell
docker run -d \ --name nitro-photos-server \ -p 8420:8420 \ -v nitro-photos-data:/data \ nitrophotos/server:latest
Or run it directly with Node — this is also how the macOS and Linux native builds run it locally during development:
Shell
git clone https://github.com/nitrophotos/server.git cd server npm install npm run start # listens on :8420 by default
Environment
PORT, DATA_FILE, and GEMINI_API_KEY (optional, for AI tagging) are the variables you're likely to set.
Storage
Photos, thumbnails, and the library index live under one data directory — back that up and you've backed up everything.
Accounts
The server keeps exactly one administrator account. The first client to connect creates it; every device after that signs in or pairs in.
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /api/v1/auth/setup | Create the one admin account. Returns a one-time recovery phrase. |
| POST | /api/v1/auth/login | Sign in, returns an access token and a refresh token. |
| POST | /api/v1/auth/refresh | Exchange a refresh token for a new access token. |
| POST | /api/v1/auth/logout | Revoke the current session. |
Example — sign in
POST /api/v1/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "••••••••",
"deviceId": "steves-mac",
"deviceName": "Steve's Mac",
"platform": "macos"
}
→ 200 OK
{
"accessToken": "…",
"refreshToken": "…",
"expiresIn": 900
}
Access tokens are short-lived (15 minutes); clients refresh silently in the background. Lost your password? Recovery needs the one-time phrase shown at setup — there's no email-reset path for a server with no mail provider configured.
Device pairing
A device without the account password — typically a phone — joins by pairing instead:
| Step | Who calls it | Endpoint |
|---|---|---|
| 1. Request a challenge | Joining device | POST /api/v1/auth/pair/start |
| 2. Approve the code | Already signed-in device | POST /api/v1/auth/pair/approve |
| 3. Redeem the challenge | Joining device | POST /api/v1/auth/pair/complete |
Challenges expire after 5 minutes. Once paired, review or revoke any device from the Server tab — revoking calls DELETE /api/v1/auth/devices/:deviceId and immediately invalidates that device's sessions, no confirmation email required since it's your own server.
Sync API
Clients reconcile their local library against the server with a small, versioned surface instead of re-uploading everything on every change.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/v1/sync/snapshot | Full library state: photos, tombstones, schema version. |
| POST | /api/v1/sync/upload/init | Start a chunked upload; returns an upload ID. |
| PUT | /api/v1/sync/upload/:uploadID | Upload one byte range. Resumable via Content-Range. |
| POST | /api/v1/sync/mutate | Apply one metadata change: favorite, tag, tag-remove, trash, or restore. |
| POST | /api/v1/sync/purge | Permanently remove a photo. |
Example — resumable upload chunk
PUT /api/v1/sync/upload/9f2a1c...
Content-Type: application/octet-stream
Content-Range: bytes 0-262143/4194304
<binary chunk>
→ 200 OK
{ "receivedBytes": 262144, "complete": false }
Keep sending sequential ranges until complete comes back true with the finished photo record. A dropped connection mid-upload just means resending from receivedBytes, not starting over.
Example — tag mutation
POST /api/v1/sync/mutate
Content-Type: application/json
{ "id": "photo-lzyaad058", "action": "tag-remove", "tag": "duplicate" }
Metadata mutations resolve with field-level last-write-wins by timestamp — except tags, which merge as a set union, so two devices adding different tags to the same photo don't clobber each other. Removing a tag is its own explicit action rather than inferred from a missing entry in a payload.
Editing & export
The Mac app's editor works on your local copy — adjustments (exposure, contrast, brightness, saturation, warmth), crop presets including 9:16 and 16:9, and AI actions (auto-enhance, 2× upscale) all preview non-destructively before you commit. Save Edits writes back to the original record; Save as New creates a second photo instead. Export As… encodes a stripped copy directly to JPEG, PNG, or HEIC — export goes straight from decoded pixels, so it never carries GPS or EXIF, even if the source file had it.
Local sharing
Each Mac can run a local share server on your own network (default port 8765) for one-off links to specific photos or albums, optionally password-protected with an expiry. It's off the public internet by default — a link only resolves for someone who can already reach your network, or through whatever port-forwarding or tunnel you choose to set up yourself.
Errors & retries
Every sync/auth endpoint returns a JSON body with an error string on failure. Clients treat these as retryable unless the status code says otherwise:
| Status | Meaning | Client behavior |
|---|---|---|
401 | Expired or invalid access token | Refresh silently, retry once |
403 | Device revoked or session mismatch | Sign out locally, prompt re-pairing |
404 | Photo, device, or challenge not found | Drop the local reference, no retry |
409 | Non-contiguous upload byte range | Resume from the returned receivedBytes |
5xx | Server error | Back off and retry |
Setup issue, not an API question? See support. Data-handling question? See the privacy policy.