Self-hosted observation log and photo tracker for deep-sky astronomy. Upload an image, let the server pull EXIF (date, GPS, telescope) and match it against a seeded Messier + Caldwell catalog, track your progress toward completing each list, and publish a public gallery and map of where you image from.
- Catalogs. 9 built-in lists / 527 objects: Messier (110), Caldwell (109), Finest NGC (119), Local Group (22), AL Globulars (50), Open Clusters for Smart Scopes (40), Planetary Nebulae for Smart Scopes (30), Sharpless 2 Bright (29), Solar System (9). Cross-list aliases mean a single upload of e.g. M42 also ticks NGC 1976 in any list that names it.
- Tonight + Planner.
/tonight.htmlshows what's up right now;/planner.htmlprojects altitude across an entire night for a chosen date and location. - Solar System ephemeris. Sun, Moon, and the eight major planets
carry an
ephemeristag instead of stored coordinates; the server computes live RA/Dec/magnitude on every fetch. - Photo gallery + map of every observation. Map is driven by EXIF GPS; Aladin Lite finder chart embedded on each object page.
- Admin behind HTTP Basic Auth with a configurable port and a version
chip in the header that compares against
mainon GitHub. Per-IP rate limit on auth failures. - Drag-and-drop upload with EXIF + watermark OCR + JSON sidecar parsing — drop a Seestar JPG and the form pre-fills target, telescope, date, location, exposure, gain, filter. Tesseract.js language data is bundled with the Docker image so OCR works on first upload.
- Multi-attempt support. Log many photos of the same object, mark one as featured for the cover image, compare any two side-by-side.
- Edit / delete / feature every observation. Equipment library lets you keep telescopes, cameras, filters, mounts as first-class entities.
- FITS uploads (
.fit/.fits) accepted alongside JPEG/PNG; the ASCII header drives metadata and a JPEG preview is rendered for gallery display. - Plate solving via Astrometry.net (set
ASTROMETRY_API_KEY) — kicks off a job, polls for results, stores RA/Dec/FOV/orientation back on the observation. - Backups.
./backup.shsnapshots the database + uploads. The admin dashboard lists every archive with a one-click Restore. - Activity heatmap. Last 365 days of observations on the admin dashboard, GitHub-contribution-graph style.
- CSV export of every observation, all 30+ columns.
- Smoke test suite.
npm testboots a throwaway server and drives every endpoint; runs in ~1 s; CI runs it on every push.
┌──────────────────────────┐
browser ──────────────► │ Express 4 (server.js) │
(public) │ │
│ ├── / (public static) │
│ │ public/*.html + js │
│ ├── /api/* (read-only) │
│ ├── /admin (static) │──── HTTP Basic Auth
│ └── /api/admin/* (RW) │ + IP rate-limit
│ │
│ sharp → thumbnails │
│ exifr → EXIF / GPS │
│ multer → uploads │
└──────────┬───────────────┘
│ better-sqlite3
▼
data/deepskylog.sqlite (WAL) uploads/YYYY/MM/<slug>/ data/stage/
┌ observations ├── original-<rand>.jpg (in-flight admin
├ lists (seeded: messier, └── thumb-<rand>.jpg uploads, auth-only)
│ caldwell)
├ list_objects (219 rows
│ pre-populated)
└ list_completions (junction)
- Admin drops a file on
/admin/upload.html. - Browser
POST /api/admin/stage(multipart).multerdrops the file intodata/stage/,exifrextracts metadata,matchTelescope()maps the EXIF device to a known telescope. The response includes a staged id + parsed metadata. - Admin reviews the preview and confirms the form.
- Browser
POST /api/admin/observations(JSON). The server renames the stage file touploads/YYYY/MM/<slug>/<rand>.<ext>, sharp writes a 640px thumbnail, a transaction inserts theobservationsrow and onelist_completionsrow per matchinglist_object(so the same upload can tick every list the object belongs to). - Stale staged files are swept on startup and hourly (24h TTL).
.
├── server.js – Express app, all API + static mounts
├── db/
│ ├── index.js – open + migrate + seed + EXIF-coord backfill
│ ├── schema.js – ordered migrations list
│ └── seed/
│ ├── messier.js – 110 objects (name, type, RA, Dec, mag, constellation)
│ └── caldwell.js – 109 objects, same schema
├── public/ – public site (dashboard, list, gallery, map, object)
│ ├── js/
│ │ ├── common.js – fetch + DOM helpers, RA/Dec formatting
│ │ ├── dashboard.js
│ │ ├── list.js
│ │ ├── gallery.js
│ │ ├── map.js – Leaflet bootstrap
│ │ └── object.js
│ └── *.html
├── admin/ – admin UI, served behind Basic Auth
│ ├── index.html – admin dashboard (stats, recent uploads)
│ ├── upload.html – drag-and-drop uploader
│ ├── dashboard.js
│ ├── upload.js
│ └── admin.css
├── backup.sh – tar.gz snapshot of db + uploads
├── package.json
└── .env.example
| table | purpose |
|---|---|
lists |
Observing lists (messier, caldwell, user-created). |
list_objects |
Catalog entries (M1 … M110, C1 … C109) with RA/Dec/mag/type. |
observations |
Every logged photo: date, location, telescope, rating, EXIF … |
list_completions |
Junction: each time an observation satisfies a list entry. |
migrations |
Applied migration ids (managed by db/schema.js). |
observations.latitude / longitude are populated from EXIF GPS on upload and
drive the Leaflet map. An install-time backfill pulls coords from any existing
exif_json blobs after the migration that adds the columns.
| Route | Auth | Notes |
|---|---|---|
/, /list.html, … |
✗ | Public static pages. |
GET /api/lists |
✗ | |
GET /api/lists/:slug |
✗ | Returns list + objects + observed flag. |
GET /api/observations |
✗ | telescope=, object_type=, has_image=1. |
GET /api/observations/map |
✗ | Only rows with non-null lat/lon. |
GET /api/observations.csv |
✗ | Downloadable snapshot. |
GET /api/objects/:id |
✗ | Object + memberships + observations. |
GET /api/filters |
✗ | Distinct telescopes + object types. |
/admin/* (static) |
Basic | Dashboard + upload UI. |
GET /api/admin/stats |
Basic | Totals + recent uploads + telescope counts. |
GET /api/admin/objects |
Basic | Autocomplete over seeded catalogs. |
POST /api/admin/stage |
Basic | Stage an upload; returns EXIF + telescope guess. |
GET /api/admin/stage/:id/preview |
Basic | Preview a staged file. |
DELETE /api/admin/stage/:id |
Basic | Discard an in-flight upload. |
POST /api/admin/observations |
Basic | Finalize: move file, thumbnail, insert rows. |
Basic Auth compares the submitted password with ADMIN_PASSWORD using
crypto.timingSafeEqual. Failed attempts are counted per client IP with a
sliding 15-minute window; after 20 failures the middleware returns
HTTP 429 Retry-After.
- Node.js 18 or newer.
- ~150 MB free for
node_modules, a bit more for photos. - A reverse proxy for TLS if you expose this on the public internet (Caddy / nginx / Cloudflare Tunnel all work — see notes below).
git clone https://github.com/kylecaulfield/DeepSkyLog.git
cd DeepSkyLog
npm installcp .env.example .envEdit .env:
ADMIN_PASSWORD=choose-a-long-random-password
PORT=3000
UPLOAD_DIR=./uploads
# Optional:
# STAGE_DIR=./data/stage # in-flight admin uploads (default is under data/)
# DATABASE_PATH=./data/deepskylog.sqlite
# BACKUP_DIR=./backups
# BACKUP_KEEP=14 # number of snapshots to keep (default 14)ADMIN_PASSWORD is required for any write operation; without it the entire
/admin section refuses to serve.
There is no user database, session table, or token store. The trust model is:
- Where the secret lives.
ADMIN_PASSWORDis read from.env(or the process environment) at startup and held only in process memory. Nothing is ever written to disk; the file you pointEnvironmentFile=at on systemd or the Docker / Unraid template variable is the single source of truth. - How requests authenticate. Every request to
/admin/*and/api/admin/*re-authenticates by comparing the supplied HTTP Basic Auth password toADMIN_PASSWORDwithcrypto.timingSafeEqual. The username is ignored; only the password is checked. There is no JWT, no cookie, and no server-side "logged in" state. - Browser-side caching. After the first 401 challenge the browser caches your credentials per-origin and resends them automatically — that's why you only get prompted once. To "log out" you close the browser (or clear site data for the host).
- Rate limiting. Failed attempts are tracked per client IP in a 15-minute
sliding window, capped at 20 failures, after which the middleware returns
HTTP 429 with
Retry-After. The window is held only in process memory and resets on restart. - Rotating the password. Edit
.env(or the container variable), then restart the process. Browsers that have your old password cached will see a fresh 401 and re-prompt. - Picking a value. Any non-empty string works, but since it gates the
entire write surface, pick something long and random — for example
openssl rand -base64 24. Paste it from a password manager rather than typing it.
If ADMIN_PASSWORD is unset, the server logs a warning at startup and the
/admin UI plus every /api/admin/* endpoint return HTTP 503 — the public
read-only surface still works.
npm startFirst boot runs all database migrations, seeds the 110 Messier and 109 Caldwell
objects, and creates uploads/ and data/stage/. You should see:
DeepSkyLog listening on http://localhost:3000
Database: /.../data/deepskylog.sqlite
Upload dir: /.../uploads
Visit http://localhost:3000/ for the public site and
http://localhost:3000/admin/ to log in with the admin password (any username
works).
- In
/admin/upload.html, drop a JPG/PNG onto the drop zone. - EXIF is parsed server-side. If the camera model matches a Seestar the telescope is auto-selected.
- Type the object name — the input autocompletes from the seeded catalogs.
- Confirm date, location, rating, notes, and save.
- The file lands in
uploads/YYYY/MM/<object-slug>/, a thumbnail is generated, and the matching catalog entry is marked observed on the public dashboard.
Create /etc/systemd/system/deepskylog.service:
[Unit]
Description=DeepSkyLog
After=network.target
[Service]
Type=simple
User=deepsky
WorkingDirectory=/opt/DeepSkyLog
EnvironmentFile=/opt/DeepSkyLog/.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetThen:
sudo systemctl daemon-reload
sudo systemctl enable --now deepskylog
sudo journalctl -u deepskylog -fdeepsky.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:3000
}Caddy will fetch and renew TLS automatically. The important bits for any proxy:
- Forward to the local
PORTyou chose. - Preserve the
Authorizationheader (default). - Allow large request bodies (default limit in the app is 50 MB).
./backup.shWrites backups/deepskylog-<UTC-stamp>.tar.gz containing:
- The SQLite database, snapshotted via
sqlite3 .backupwhen the CLI is available (falls back to a file copy of the.sqlite+-wal+-shm). - The
uploads/tree, minus.stage/. - A
.sha256sidecar next to the archive.
Archives older than BACKUP_KEEP (default 14) are pruned automatically. Hook
into cron for daily snapshots:
0 3 * * * cd /opt/DeepSkyLog && ./backup.sh >> /var/log/deepskylog-backup.log 2>&1Restore is just:
systemctl stop deepskylog
tar -xzf deepskylog-<stamp>.tar.gz -C /opt/DeepSkyLog/data ./deepskylog.sqlite
tar -xzf deepskylog-<stamp>.tar.gz -C /opt/DeepSkyLog ./uploads
systemctl start deepskylogMigrations are additive and idempotent — they run on every boot and track
applied ids in a migrations table.
cd /opt/DeepSkyLog
git pull
npm install
systemctl restart deepskylogTake a backup first; always.
An OCI image is published on every push to main at
ghcr.io/kylecaulfield/deepskylog, built for linux/amd64 and linux/arm64
by .github/workflows/docker.yml. The image bundles eng.traineddata.gz
under vendor/tessdata/ (~10 MB, downloaded by an npm install postinstall
hook) so OCR of the Seestar watermark band works out of the box without an
internet round-trip on first upload. Set DISABLE_OCR=1 or SKIP_TESSDATA=1
to opt out of the download / runtime use. Tags:
latest— tip ofmain.main— alias of the above.<shortsha>— the exact commit.
All mutable state lives under /data inside the container (database + staged
uploads + finalized uploads + backup archives), so one bind mount is enough.
mkdir -p /srv/deepskylog/data
# The image runs as UID 1000 (the `node` user). Make sure the host dir matches.
sudo chown -R 1000:1000 /srv/deepskylog/data
# HOST_PORT defaults to 3000; pick anything free on the host.
HOST_PORT=${HOST_PORT:-3000}
docker run -d \
--name deepskylog \
-p "${HOST_PORT}:3000" \
-e ADMIN_PASSWORD='change-me' \
-v /srv/deepskylog/data:/data \
--restart unless-stopped \
ghcr.io/kylecaulfield/deepskylog:latestservices:
deepskylog:
image: ghcr.io/kylecaulfield/deepskylog:latest
container_name: deepskylog
restart: unless-stopped
ports:
# Map ${HOST_PORT:-3000} on the host to 3000 inside the container.
- "${HOST_PORT:-3000}:3000"
environment:
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}
# PORT: 3000 # in-container listen port (rarely worth changing)
# BACKUP_KEEP: 14
volumes:
- ./data:/dataDrop a sibling .env next to the compose file with HOST_PORT= and
ADMIN_PASSWORD= lines and docker compose will pick them up automatically.
There are two distinct knobs:
- Host port — the side that's reachable on your network. Change it by
editing the
-pmapping (-p 8080:3000orHOST_PORT=8080). The in-container port stays 3000 and you don't need to rebuild. - In-container port — what
node server.jslistens on. Override with-e PORT=8080(and update the-pmapping to-p HOST:8080). TheEXPOSEdirective is rebaked at build time from thePORTbuild-arg, so if you want the image's metadata to advertise a different port, build locally withdocker build --build-arg PORT=8080 -t deepskylog ..
The healthcheck reads process.env.PORT, so any runtime override is picked up
automatically.
The image doesn't ship with a Community Apps template yet, so add it manually as a custom container. Copy-paste values into the corresponding fields in Unraid's Docker → Add Container dialog.
-
Prepare the appdata directory (Unraid terminal or SSH as root):
mkdir -p /mnt/user/appdata/deepskylog chown -R 1000:1000 /mnt/user/appdata/deepskylog
The container runs as UID 1000, so the appdata dir has to be owned by 1000 or readable+writable by it. Unraid's default
nobody:users(99:100) will produce permission errors on first run; do not skip this step. -
Docker → Add Container. Switch template mode to Basic and fill in:
Field Value Name deepskylogRepository ghcr.io/kylecaulfield/deepskylog:latestNetwork Type BridgeIcon URL (optional) any square PNG WebUI http://[IP]:[PORT:3000]/Extra Parameters --init(reaps zombies cleanly) -
Click Add another Path, Port, Variable, Label or Device and add:
Port
- Name:
WebUI - Container Port:
3000(leave at the image default) - Host Port:
3000— change this if 3000 is already taken on Unraid; the WebUI URL above re-reads[PORT:3000]so it'll follow your choice. - Connection Type:
TCP
Path
- Name:
Data - Container Path:
/data - Host Path:
/mnt/user/appdata/deepskylog - Access Mode:
Read/Write
Variable (required)
- Name:
ADMIN_PASSWORD - Key:
ADMIN_PASSWORD - Value: a long random password of your choice
Variable (optional)
- Name:
BACKUP_KEEP - Key:
BACKUP_KEEP - Default:
14
- Name:
-
Click Apply. Unraid pulls the image, creates the container, and starts it. Watch Docker → deepskylog → Log until you see
DeepSkyLog listening on http://localhost:3000. -
Open
http://<UNRAID-IP>:3000/for the public site. Go to/admin/(http://<UNRAID-IP>:3000/admin/) and log in with any username and theADMIN_PASSWORDyou set.
Everything lives under /mnt/user/appdata/deepskylog/:
deepskylog/
├── deepskylog.sqlite # the SQLite database
├── deepskylog.sqlite-wal # WAL journal (safe to snapshot)
├── deepskylog.sqlite-shm
├── uploads/ # YYYY/MM/<object-slug>/<image>.jpg
│ └── …
├── stage/ # in-flight admin uploads (auto-swept after 24h)
└── backups/ # written by ./backup.sh if you run it
You have two options:
- Preferred: back up
/mnt/user/appdata/deepskylog/with CA Backup / Restore Appdata on your preferred schedule. Stop the container first (the CA plugin does this for you). - Inside the container:
docker exec -u node deepskylog ./backup.shproduces a tarball at/data/backups/deepskylog-<UTC>.tar.gzwhich you can then sync off-box.
Click Docker → deepskylog → Force update (Unraid pulls the newest
:latest tag and recreates the container). Migrations are additive and run on
every boot.
Point SWAG / Nginx Proxy Manager at http://<UNRAID-IP>:3000 and put DeepSkyLog
behind your existing TLS certificate. Preserve the Authorization header
(default in both proxies) and allow at least 50 MB request bodies so the
admin upload form works.
npm run dev # node --watch server.jsThe public site is plain HTML/CSS/ES-modules — no build step. Edit any file
under public/ or admin/ and refresh.
rm -rf data uploads
npm startThe next start re-runs migrations and reseeds the Messier + Caldwell lists.
| Variable | Default | Purpose |
|---|---|---|
PORT |
3000 |
HTTP port. |
ADMIN_PASSWORD |
— (required for writes) | Basic Auth password for /admin. |
UPLOAD_DIR |
./uploads |
Where final photos + thumbnails live. |
STAGE_DIR |
<db dir>/stage |
Where staged uploads live (non-public). |
DATABASE_PATH |
./data/deepskylog.sqlite |
SQLite file path. |
BACKUP_DIR |
./backups |
Where backup.sh writes archives. |
BACKUP_KEEP |
14 |
Number of archives to retain. |
MIT.