Documentation

Running Nitro Photos

Architecture, server setup, pairing, editing, sharing, and the sync API

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.

MethodEndpointPurpose
POST/api/v1/auth/setupCreate the one admin account. Returns a one-time recovery phrase.
POST/api/v1/auth/loginSign in, returns an access token and a refresh token.
POST/api/v1/auth/refreshExchange a refresh token for a new access token.
POST/api/v1/auth/logoutRevoke 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:

StepWho calls itEndpoint
1. Request a challengeJoining devicePOST /api/v1/auth/pair/start
2. Approve the codeAlready signed-in devicePOST /api/v1/auth/pair/approve
3. Redeem the challengeJoining devicePOST /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.

MethodEndpointPurpose
GET/api/v1/sync/snapshotFull library state: photos, tombstones, schema version.
POST/api/v1/sync/upload/initStart a chunked upload; returns an upload ID.
PUT/api/v1/sync/upload/:uploadIDUpload one byte range. Resumable via Content-Range.
POST/api/v1/sync/mutateApply one metadata change: favorite, tag, tag-remove, trash, or restore.
POST/api/v1/sync/purgePermanently 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:

StatusMeaningClient behavior
401Expired or invalid access tokenRefresh silently, retry once
403Device revoked or session mismatchSign out locally, prompt re-pairing
404Photo, device, or challenge not foundDrop the local reference, no retry
409Non-contiguous upload byte rangeResume from the returned receivedBytes
5xxServer errorBack off and retry

Setup issue, not an API question? See support. Data-handling question? See the privacy policy.