Skip to content
AH
← Back to all projects
distributed • Case Study

NowHere: Nearby snaps on NestJS microservices

Nearby snaps — NestJS, NATS, Expo. Local compose demo.

Expo client walkthrough — nearby map, snap detail, capture, profile

5
Backend services
Gateway, auth, users, snaps, storage in one Nx workspace
Presigned PUT
Snap photos
Camera bytes go to MinIO/S3/GCS; the gateway stores object keys
2dsphere
Nearby query
Mongo $geoWithin $centerSphere, $near as fallback
Socket.IO
Live updates
In-process Haversine over connected clients on the snaps process
Stack
NestJS NATS MongoDB Expo Socket.IO Redis MySQL Docker

What it is

This is a local Compose demo / portfolio system, not a hosted product. There is no production URL.

NowHere is a location-based social app you run on your machine. Open the map, see photo snaps other people posted near you, drop your own. The recording at the top is the Expo client: nearby map, snap detail, capture, and profile. Each snap has a caption, a tag, a GeoJSON point, and an expiry (author setting, default 1 day). You can post at most one snap per area per day (author newSnapDistance, default 1 km). Lost/found snaps can be marked found and reopened. Bookmarks and reports live in MySQL; hiding a snap for yourself is local MMKV only.

Selectable tags in the client:

TagMeaning
SOCIALA nearby moment
INTERESTINGFlagged as worth a look
HIDDEN_GEMA local spot that is easy to miss
FINDINGSSomething was found here
LOSTSomething was lost here
PROMOTIONA nearby offer or pop-up

HTTP from the phone always hits one gateway. The gateway never opens MySQL, Mongo, or S3 itself — it calls domain services over NATS. Socket.IO is the exception: the client opens a websocket straight to the snaps process.

Source:


Topology

Client HTTP goes to the gateway on :3005. Socket.IO goes around the gateway, to snaps on :3000. Kubernetes ingress is written the same way (/ → gateway, /socket.io → snaps).

System topology Figure 1. Client HTTP hits the gateway; Socket.IO hits snaps; services share NATS; each domain owns its store.

Local ports (dev compose publishes all of these):

ProcessPortStore
API gateway3005none
Snaps (HTTP + Socket.IO)3000MongoDB
Users3001MySQL Users_Info
Storage3002MinIO/S3/GCS + Redis
Authentication3004MySQL Users_Credentials
NATS4222 (monitor 8222)JetStream file store
Redis6379signed download URL cache
MinIO9000 / console 9001object store
MySQL3307 → 3306auth + users
MongoDB27017snaps
Mailhog (dev)1025 / UI 8025password-reset mail

Production-like compose publishes only gateway :3005 and snaps :3000. Auth, users, and storage stay on the internal network.


The five services

Nx 21 + pnpm workspaces. Apps under apps/, shared code under libs/contracts (Zod schemas + NATS subject constants) and libs/nowhere-common (bootstrap, filters, NATS helpers, geo, health, storage-key rules).

Service sketch Figure 2. Controllers, stores, and NATS families.

1. API gateway (apps/gateway, :3005)

The only public HTTP API. Nest HTTP app that calls natsRequest(...) for domain work. Default RPC timeout 8 s (login/signup and account delete 15 s).

  • Helmet always on. CORS required in production (CORS_ORIGIN comma-separated). Credentials enabled.
  • GatewayAuthGuard: verifies the access JWT locally with ACCESS_SECRET. Injects request.user. Does not call auth over NATS on every request.
  • GET /auth/me: returns that decoded user. No NATS hop.
  • DataResponseInterceptor: wraps HTTP success as { success: true, data } except /health and /docs.
  • HttpExceptionFilter: Content-Type: application/problem+json (RFC 9457 fields type, title, status, detail, instance, plus timestamp).
  • Throttling: 60 req / 60s globally; login/signup 5 / 60s; forgot/reset 5 / 15 min. /health is skipped.
  • Admin routes (RoleGuard): list all users/snaps/files, delete all snaps.
  • Swagger at /docs when ENABLE_SWAGGER=true.

2. Authentication (apps/authentication, :3004)

Owns credentials (uuid, unique email, bcrypt hash, isActive, role ADMIN | USER, lastLoginAt, lockout fields) and password_reset_tokens.

NATS subjects:

auth.validateUser
auth.signup
auth.refreshToken
auth.changePassword
auth.forgotPassword
auth.resetPassword
auth.deleteCredentials
auth.deactivateUser

Passwords are bcrypt (genSalt() default 10 rounds). Tokens are HS256 with ACCESS_SECRET / REFRESH_SECRET. Access default 15m, refresh default 7d. Refresh is stateless: the refresh JWT is verified and a new pair is issued. There is no refresh-token store.

After 5 failed logins the account locks for 15 minutes. Password policy: min 8, one upper, one lower, one number, one symbol. Forgot-password always returns 202 (no email enumeration). Reset tokens are 32 random bytes, stored as HMAC-SHA256, TTL 1 hour, single-use. Dev compose sends mail through Mailhog.

On signup (and on admin seed), auth publishes JetStream event auth.user.credentials.created.

3. Users (apps/users, :3001)

Owns users, settings, snap_seen, snap_bookmark, snap_report. Settings defaults on the entity: maxDistance 10 000 m, newSnapDistance 1 000 m, snapDisappearTime 1 day. Presets the client can pick: 1/5/10/25 km, 250/500/1000/2000 m, 1/3/7 days. If the settings RPC fails, nearby falls back to 5 km.

NATS subjects include profile, settings, seen, bookmarks, reports, DSGVO export, and purge:

users.getSettings
users.updateSettings
users.updateProfile
users.getUserById
users.setUserPhoto
users.setSeenSnap
users.notSeenSnaps
users.addBookmark
users.removeBookmark
users.listBookmarks
users.createReport
users.exportUser
users.purgeUser

A JetStream durable consumer users-auth-credentials on stream AUTH_EVENTS creates the profile row when credentials are created. Explicit ack, max_deliver: 5, backoff 2s / 10s / 30s / 60s.

4. Snaps (apps/snaps, :3000)

Mongo document: description (max 280), snaps (1–4 object keys), _userId, GeoJSON Point [lng, lat], tag, status, expiresAt, optional idempotencyKey, lost/found resolution. Indexes: location: 2dsphere, { createdAt: -1, location: '2dsphere' }, { expiresAt: 1 }, unique sparse { _userId, idempotencyKey }.

NATS subjects:

snaps.findNear
snaps.findSeen
snaps.findOne
snaps.findAll
snaps.findByTags
snaps.findByUser
snaps.create
snaps.deleteOne
snaps.deleteAll
snaps.deleteByUserId
snaps.markFound
snaps.reopen

GET /snaps/near/:lng/:lat is unseen nearby snaps. GET /snaps/seen/:lng/:lat is the seen list. Both take optional tags. Opening GET /snaps/:id marks the snap seen. GET /snaps/me lists the author’s snaps (includeExpired defaults true).

A TTL job runs every 15 minutes, batch 100: delete object keys then the Mongo docs.

Socket.IO lives here, not on the gateway. Handshake JWT from auth.token or Authorization. Clients emit locationChange with [lng, lat]. On create, handleNewSnap walks the in-memory map, keeps sockets within 100 km Haversine, and emits snap-added.

5. Storage (apps/storage, :3002)

storage.uploadPhoto
storage.getSignedUrl
storage.getSignedUrls
storage.getPresignedUpload
storage.listFiles
storage.deleteFiles

STORAGE_PROVIDER in {gcp,gcs,google} binds GCSStorageStrategy. Anything else (default aws) binds S3StorageStrategy with forcePathStyle: true, so MinIO and R2-compatible endpoints work by setting AWS_ENDPOINT_URL. There is no separate MinIO class.

Download URLs are cached in Redis. storage.service.ts falls back to CACHE_TTL = 86340 seconds when the env var is unset. CacheModule itself defaults to 3600 if the env is missing at module init — k8s example sets CACHE_TTL: '86340' so they agree. Presigned PUT URLs expire in 3600 s.


Auth fan-out (the JetStream path)

Signup is request-reply plus a durable event, because the credentials row and the profile row live in different MySQL databases.

Expo                 Gateway :3005              Auth :3004                 NATS JetStream              Users :3001
 │                     │                          │                            │                          │
 │ POST /auth/signup   │                          │                            │                          │
 │                     │  NATS auth.signup        │                            │                          │
 │                     │─────────────────────────►│ bcrypt.hash                │                          │
 │                     │                          │ INSERT credentials         │                          │
 │                     │                          │ publish ──────────────────►│ AUTH_EVENTS              │
 │                     │                          │ auth.user.credentials.created                         │
 │                     │                          │ sign HS256 access+refresh  │  durable consumer        │
 │  { user, tokens }   │◄─────────────────────────│                            │  users-auth-credentials  │
 │                     │                          │                            │─────────────────────────►│ INSERT users
 │ GET /auth/me        │  (no NATS — JWT only)    │                            │                          │
 │◄────────────────────│                          │                            │                          │

If the users consumer is down, JetStream keeps the message. The client can already hold tokens; the profile appears when the consumer catches up. The Expo client retries settings 404 after signup for that reason.

Account delete is the reverse orchestration on the gateway: deactivate auth → snaps.deleteByUserId → users.purgeUser → auth.deleteCredentials. A later-step failure returns 500 ACCOUNT_DELETE_INCOMPLETE.


Snap media: presign, PUT, then metadata

Camera photos do not go through Nest. The client asks the gateway for a PUT URL, uploads bytes to MinIO/S3/GCS, then posts keys.

Presigned upload sequence Figure 3. POST /storage/presigned-upload → NATS storage.getPresignedUpload → client PUT → POST /snaps with object keys.

Gateway rules:

  • Batch limit 4 (MAX_UPLOAD_BATCH / MAX_SNAP_IMAGES).
  • Content type allow-list: jpeg / png / webp.
  • Object key is owned: snaps/{date}/{userId}/{ts-rand}.ext. The gateway builds the key; the client cannot pick an arbitrary path.
  • POST /snaps then assertSnapImageKeys so you cannot attach someone else’s key.
  • Optional idempotencyKey (UUID). Same user + key returns the existing snap. The Expo client does not send one; the compose smoke test does.

Create also enforces “already posted in this area today”: a $near query with newSnapDistance in the disappear window → 403 SNAP_ALREADY_IN_AREA.

Profile photos are a different path. PUT /users/image streams the file through the gateway (5 MiB FileInterceptor) and over NATS as a buffer to storage.uploadPhoto, key profile/{userId}.


Nearby query vs live fan-out

Three distances, three jobs:

MechanismDefaultWhere
HTTP nearby queryuser settings.maxDistance (10 km), or 5 km if settings fetch failsMongo $geoWithin + $centerSphere (radians), fallback $near + $maxDistance
Duplicate-post guardnewSnapDistance 1 kmMongo $near
Socket.IO fan-outhardcoded 100 kmin-memory Haversine over connected sockets

The 100 km figure is not the map query radius. The map query is a few kilometres. The socket walk is a coarse “is this client roughly around this snap?” filter so distant sockets do not get snap-added. Clients that were offline pick the snap up on the next GET /snaps/near.

Haversine lives in libs/nowhere-common/src/utils/geo.ts (haversineMeters, earth radius 6 371 000 m). The Socket.IO gateway filters a Map.

Socket.IO proximity Figure 4. After Mongo insert, snaps walks connected sockets and emits snap-added inside 100 km.

// apps/snaps/src/snaps/gateway.ts — in-memory, single process
findNearbyUsers(lat: number, lng: number, radiusKm: number) {
  const radiusInMeters = radiusKm * 1000;
  return Array.from(this.usersLocationMap.values()).filter((user) => {
    return (
      haversineMeters(lat, lng, user.coordinates[1], user.coordinates[0])
      <= radiusInMeters
    );
  });
}

The comment above that map:

Does not survive process restart or a second snaps replica — sticky sessions or a shared store is a later phase.

Kubernetes currently sets replicas: 1 for every app, which matches that.


NATS: request-reply for RPC, JetStream for one event

NATS Core vs JetStream as used Figure 5. Left — gateway RPC. Right — stream AUTH_EVENTS.

Request-reply (NATS Core client.send / @MessagePattern) is how the gateway answers HTTP, and how snaps talks to users (settings, seen) and storage (signed URLs, deletes).

JetStream stream AUTH_EVENTS, subjects auth.user.>, file storage, max age 7 days. One publisher (auth), one durable consumer (users). Snap create does not publish a JetStream event.

Shared contract package so frontend TypeScript and backend Nest do not drift on payload shape: Zod schemas in libs/contracts.


Storage wiring

Storage and secrets strategies Figure 6. Storage is selected at StorageModule boot. SecretManagerModule in nowhere-common is optional library code; apps currently boot from ConfigModule / .env.

// apps/storage/src/storage.module.ts
useFactory: (configService: ConfigService) => {
  if (isGcpStorageProvider(configService.get('STORAGE_PROVIDER'))) {
    return new GCSStorageStrategy(configService);
  }
  return new S3StorageStrategy(configService);
}

Gateway HTTP the phone actually calls

REST from Expo goes to EXPO_PUBLIC_GATEWAY_URL with a domain prefix (/auth, /users, /snaps, /storage). Socket URL is separate: EXPO_PUBLIC_SNAPS_SOCKET_URL.

MethodPathAuthBackend hop
POST/auth/login, /auth/signuppublic (throttled)auth.validateUser / auth.signup + JetStream
POST/auth/forgot-passwordpublicauth.forgotPassword (always 202)
POST/auth/reset-passwordpublicauth.resetPassword
GET/auth/refreshrefresh JWTauth.refreshToken
GET/auth/meaccess JWTnone
GET/PUT/users/settingsaccess JWTsettings RPC
PATCH/users/meaccess JWTusers.updateProfile
GET/users/me/exportaccess JWTDSGVO JSON (object keys, not signed URLs)
DELETE/users/meaccess JWT + passwordgateway orchestrator
GET/PUT/DELETE/users/me/bookmarks[/:snapId]access JWTbookmark RPCs
PUT/users/imageaccess JWT, multipartusers.setUserPhoto
GET/snaps/near/:lng/:lataccess JWTsnaps.findNear
GET/snaps/seen/:lng/:lataccess JWTsnaps.findSeen
GET/snaps/meaccess JWTsnaps.findByUser
GET/snaps/:idaccess JWTsnaps.findOne + signed URLs
POST/snapsaccess JWTsnaps.create
DELETE/snaps/:idowner or adminsnaps.deleteOne
POST/snaps/:id/found, /reopen, /reportaccess JWTresolution / report
POST/storage/presigned-uploadaccess JWTstorage.getPresignedUpload
GET/healthpublicTerminus

Success: { success: true, data }. Errors: problem+json. The Expo HttpClient accepts both, unwraps the envelope, and maps problem details onto ApiError.


Mobile client (Expo 53)

React Native 0.79, Expo Router 5, NativeWind. Feature folders: features/auth, features/snaps, features/users, features/map. Language toggle (DE/EN) is device-only, stored in MMKV — not a gateway setting.

Client layers Figure 7. Screens → TanStack Query / Zustand → HttpClient + Socket.IO. Map rendering is MapLibre (OpenFreeMap liberty style); ReactNativeMapsStrategy sits behind the same interface.

Screens (expo-router):

RouteJob
onboarding, location-consentFirst-run + location
(tabs)/indexMap of nearby snaps
(tabs)/add-snapTab press → camera modal (or login)
(snaps)/snaps-captureexpo-camera
(snaps)/snap-inputsDescription + tag, then presign/PUT/create
(snaps)/[id]Snap detail + signed images
(auth)/login, signup, forgot-password, reset-passwordCredentials
(tabs)/profile, settings, my-snaps, savedProfile, settings, own snaps, bookmarks
(legal)/privacyPrivacy copy

Session. Access and refresh tokens live in Expo SecureStore. useAuthSession hydrates from SecureStore, then GET /auth/me. 401 logs out. A network error keeps the local session so a cold start offline does not bounce you to login.

MMKV persists Zustand slices that are not secrets: last location, onboarding flag, user profile cache, snap draft, hidden-snap ids, locale.

Nearby UI is a map, not a list. Markers are colored by tag; a bottom sheet filters tags and “new nearby” vs “already opened.”

Live sockets. useSnapSocket connects with the access token, emits locationChange whenever lng/lat change, and upserts on snap-added. Location is not continuously watched (watchPositionAsync is unused).

Uploads. Camera capture uses JPEG quality 0.85. Presigned PUT URLs that come back as minio-local / localhost are rewritten to the phone’s LAN host so a device can hit Docker MinIO.

HTTP. Shared HttpClient with single-flight refresh, retry unless 4xx, Accept: application/json, application/problem+json.


Kubernetes and compose

k8s/apps.yaml + infra.yaml + ingress.yaml:

  • Five Deployments, replicas: 1, non-root uid 1000, /health probes.
  • Gateway Service is a LoadBalancer on 3005. Everything else ClusterIP.
  • Ingress: /socket.io → snaps:3000, / → gateway:3005.
  • Secrets are gitignored; k8s/secrets.example/ holds change_me_* placeholders.

Tests

Backend: Jest unit specs per service (auth bcrypt/JWT/lockout/reset, gateway signup→presign→create→near with mocked NATS, snaps geo query shape and 100 km emit, TTL, found/reopen, storage cache hit/miss, bookmarks/reports/export/purge) plus a local compose smoke (pnpm test:e2e:smoke) that hits a running gateway.

Frontend: Jest + Testing Library for ApiError, presign/create snap, MapLibre vs RN Maps, snap details, camera, tags, bookmarks, export/delete, geo helpers.

CI runs lint, typecheck, unit tests, and (backend) a no-push docker build of the gateway image. It does not start compose.


Why these choices

DecisionChoiceWhat it costs
Inter-service RPCNATS request-reply, subjects in contractsA broker to operate; worth it once there are five processes
Snap photosPresigned PUTTwo-step client protocol; key-ownership checks on create
Profile photosMultipart through gatewaySimpler; it is the bandwidth path snaps avoid
Nearby queryMongo 2dsphere + $geoWithinFlexible documents/tags; not PostGIS
Live updatesSocket.IO in-processFast to write; one snaps replica
Cross-DB signupJetStream AUTH_EVENTSProfile can lag tokens; consumer must stay up
Gateway authLocal HS256 verifyAuth service is not on the hot path for /me; rotating keys is a shared secret
PasswordsbcryptFine for this app
Errorsproblem+jsonMobile can parse detail / status uniformly
MapMapLibre strategyOSM tiles; RN Maps remains a one-line swap

Run it locally

# backend
git clone https://github.com/AliSaleemHasan/NowHere-backend.git
cd NowHere-backend
pnpm install
docker compose -f docker-compose.dev.yml up --build

curl http://localhost:3005/health   # gateway
curl http://localhost:3000/health   # snaps (Socket.IO lives here too)

pnpm test
# with compose up:
pnpm test:e2e:smoke
# mobile
git clone https://github.com/AliSaleemHasan/NowHere-frontend.git
cd NowHere-frontend
npm install
# point the phone at your LAN, not localhost
# EXPO_PUBLIC_GATEWAY_URL=http://<lan-ip>:3005
# EXPO_PUBLIC_SNAPS_SOCKET_URL=http://<lan-ip>:3000
npx expo start