A weather portal for astrophotography, with native iOS and Android companions.
Laravel 13 · Vue 3 · C# / .NET · SwiftUI · Kotlin + Jetpack Compose · PostgreSQL · Docker
Search any location and see a blended, multi-source hourly forecast (cloud cover by
altitude, seeing, transparency, wind, humidity, precipitation, visibility, pressure),
darkness/twilight windows, moon phase and rise/set, and an approximate sky-quality
(light pollution) rating — in one clear, dark-themed view. The same data and account
system is also available from native iOS and Android companion apps, which add
device location, biometric-gated sign-in, and a saved-locations/equipment/gallery
experience on top of the portal's /api/mobile/* surface.
Favorite locations, equipment, and a public photo gallery/profile are built; an observation-session log is designed but not yet built (see Roadmap below).
| Part | Stack | Role |
|---|---|---|
webapp/ |
Laravel 13 · Inertia.js · Vue 3 | The web portal — accounts, admin portal, search, forecast rendering, and the /api/* surface used by the browser and both mobile apps |
backend/AstroCast.Worker/ |
C# / .NET | Internal-only service that blends weather from three free sources, computes astronomy (twilight, moon), and persists results to Postgres — never reached directly by a browser or mobile app |
ios/AstroCast/ |
SwiftUI, iOS 17+ | Native iOS companion app, talking to the portal's /api/mobile/* endpoints over a Bearer token |
android/AstroCast/ |
Kotlin + Jetpack Compose, minSdk 26 | Native Android companion app, talking to the same /api/mobile/* endpoints |
- Portal — build & run
- Portal — deploying
- Mobile app (iOS) — build & run
- Mobile app (iOS) — deploying to TestFlight / App Store
- Mobile app (Android) — build & run
- Mobile app (Android) — deploying to the Play Store
- How a search resolves
- Architecture
- Accounts & admin portal
- Configuring outgoing email
- Roadmap
Requires Docker.
docker compose up --buildThen open http://localhost:8080. That one command builds and runs five containers:
postgres— shared data storeworker— C# service that fetches/blends weather + computes astronomy and persists it to Postgres; internal-only, never reachable from a browserlaravel-fpm— the Laravel/Inertia/Vue app (PHP-FPM process)laravel-queue—php artisan queue:work, runs background jobs (e.g. triggering the worker to refresh a location that's missing/stale)laravel-nginx— serves the built frontend assets and proxies PHP requests tolaravel-fpm; the only container with a published port (8080)
An admin account is seeded automatically from ADMIN_EMAIL/ADMIN_PASSWORD in the
repo-root .env (see Accounts & admin portal).
docker compose --profile test build laravel-test
docker compose --profile test run --rm laravel-testThis is also what CI runs on every push/PR (.github/workflows/ci.yml), against a real
Postgres service container — several admin queries use Postgres-only SQL that SQLite
can't parse, so the suite always targets Postgres, never an in-memory DB.
Run Postgres via Docker (docker compose up -d postgres, exposed on the host at the
port in .env), then:
# terminal 1 — worker
cd backend/AstroCast.Worker
dotnet run --urls http://localhost:8081
# terminal 2 — Laravel
cd webapp
php artisan serve --port=8001
# terminal 3 — queue worker (needed for the cold-path refresh flow)
cd webapp
php artisan queue:work
# terminal 4 — Vite dev server (optional; `npm run build` once also works)
cd webapp
npm run devwebapp/.env already points at the host-mapped Postgres port and
http://127.0.0.1:8081 for the worker.
The portal ships as the same five-container Docker Compose stack in both development and production — there's no separate production build system. To deploy to a server:
- Provision a host with Docker + Docker Compose, and point a domain at it (the app
assumes it sits behind HTTPS in production — either terminate TLS with a reverse
proxy such as Caddy/nginx/Traefik in front of the published
8080port, or adaptlaravel-nginx's config to terminate TLS itself). - Copy the repo to the host (or pull it via CI/CD) and create a production
.envat the repo root (never commit real secrets — copy.envand fill in real values):APP_ENV=production,APP_DEBUG=falsePOSTGRES_DB/POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_PORT— real, non-default credentialsADMIN_EMAIL/ADMIN_PASSWORD— seeds the one admin account on first bootWORKER_API_TOKEN— shared secret between Laravel and the worker's internal APIMAIL_HOST/MAIL_PORT/MAIL_USERNAME/MAIL_PASSWORD/MAIL_FROM_ADDRESS— see Configuring outgoing email; without these, email verification and clear-sky alerts silently fail to send- An
APP_KEYforwebapp/.env— generate one withdocker compose run --rm laravel-fpm php artisan key:generate --showand set it inwebapp/.env(or bake it into your image build) before first boot
- Build and start the stack:
docker compose build docker compose up -d
laravel-fpm(only) runs pending migrations and idempotent seeders on container start — gated by itsRUN_MIGRATIONS=trueenv var, solaravel-queueandlaravel-nginx(which share the same image) don't race it (seewebapp/docker/entrypoint-fpm.sh). A fresh Postgres volume is bootstrapped automatically the first time the stack comes up. - Redeploying after a code change: rebuild only the images that changed and
recreate those containers — frontend assets are compiled into the image at build
time (an
npm run buildstage inwebapp/Dockerfile), so any JS/CSS/Blade/Vue change requires a rebuild, not just a restart:For a worker-only change:docker compose build laravel-fpm laravel-nginx docker compose up -d laravel-fpm laravel-nginx
docker compose build worker && docker compose up -d worker. - Health check: once running,
/admin/status(signed in as an admin) reports live health for Postgres, the worker, and the queue worker — useful for confirming a deploy actually came up healthy.
There is no managed hosting/CI deploy step wired up in this repo yet (.github/workflows/ci.yml
only runs the test suite) — the steps above are what to automate if you add one.
Requires a Mac with Xcode 15+ (iOS 17 deployment target) and
XcodeGen (brew install xcodegen) — the
.xcodeproj is generated from ios/AstroCast/project.yml, not committed as
hand-edited project state.
cd ios/AstroCast
xcodegen generate
open AstroCast.xcodeprojThen in Xcode, select the AstroCast scheme and run on an iOS 17+ simulator (or a
device, with your own Apple Developer team selected under Signing & Capabilities —
CODE_SIGN_STYLE is Automatic).
The app points at a different API base URL per build configuration
(AstroCast/Networking/APIClient.swift):
- Debug →
http://localhost:8080— the iOS Simulator shares the host Mac's network namespace, so this reaches a locally-runningdocker compose upstack directly, no proxy or LAN IP needed.Info.plistallows insecure HTTP specifically tolocalhostfor this reason. - Release →
https://astrocast.app— a deployed portal instance.
So: start the portal locally first (docker compose up --build from the repo root),
then run the iOS app in Debug — sign-up/login/forecast/gallery all talk to that local
stack.
From Xcode: Cmd+U on the AstroCast scheme, which runs both AstroCastTests (unit —
e.g. RatingsTests.swift) and AstroCastUITests. From the command line:
cd ios/AstroCast
xcodebuild test -project AstroCast.xcodeproj -scheme AstroCast \
-destination 'platform=iOS Simulator,name=iPhone 15'(iOS builds/tests aren't currently wired into .github/workflows/ci.yml, which only
covers the portal — running them is a manual/local step today.)
- Regenerate the project (
xcodegen generate) so anyproject.ymlchanges are reflected, then open it in Xcode. - Under the
AstroCasttarget's Signing & Capabilities, select your real Apple Developer team (the bundle identifier ispl.adamlange.astrocast, set inproject.yml— change it there, not in Xcode directly, if you need your own). Note your Team ID (Membership Details in the Apple Developer portal) and set it asAPPLE_TEAM_IDin the portal's production.env— without it, the emailed verification link's Universal Link won't pass Apple's on-device AASA verification and will just open in Safari instead of the app. - Confirm the Release configuration's
APIClient.baseURL(https://astrocast.app) points at your actual deployed portal — updateios/AstroCast/AstroCast/Networking/APIClient.swiftif you're deploying under a different domain, then re-runxcodegen generate. - Select Product → Archive in Xcode (with an iOS device or "Any iOS Device" as the run destination — Archive is disabled for simulator destinations).
- In the Organizer window that opens after archiving, Distribute App →
App Store Connect → upload. This requires an Apple Developer Program membership and
an app record already created in App Store Connect for
pl.adamlange.astrocast(or your own bundle id). - From App Store Connect, add the build to a TestFlight group for internal testing, or submit it for App Review to release publicly.
Requires Android Studio (or the command-line tools) with JDK 17+ and a device/emulator running Android 8.0 (API 26) or newer.
cd android/AstroCast
./gradlew :app:assembleDebugOr open android/AstroCast in Android Studio and run the app configuration on an
emulator or device.
The app points at a different API base URL per build type
(app/build.gradle.kts → BuildConfig.API_BASE_URL):
- Debug →
http://10.0.2.2:8080— the emulator's alias for the host machine's loopback interface, reaching a locally-runningdocker compose upstack directly. A debug-only cleartextnetwork_security_configscopes plaintext HTTP to that one host. - Release →
https://astrocast.app— a deployed portal instance.
So: start the portal locally first (docker compose up --build from the repo root),
then run the Android app in Debug — sign-up/login/forecast/gallery all talk to that
local stack. (On a physical device instead of an emulator, 10.0.2.2 won't resolve —
point API_BASE_URL at your host machine's LAN IP instead.)
cd android/AstroCast
./gradlew testDebugUnitTestRuns the JVM-only unit tests (e.g. RatingsTest.kt, ported from
RatingsTests.swift) — this is also what CI runs
(.github/workflows/ci.yml's android-tests job). Compose UI tests under
app/src/androidTest/ (guest-mode navigation, login/logout, search →
forecast-detail) need a running emulator plus a live local backend, so they're a
manual/local step for now, same as iOS's UI test suite:
./gradlew connectedAndroidTest- Generate a release keystore (once) and keep it out of version control:
keytool -genkeypair -v -keystore release.keystore -alias astrocast \ -keyalg RSA -keysize 2048 -validity 10000
- Add a
releasesigning config toapp/build.gradle.ktspointing at that keystore (via environment variables or a local, gitignoredkeystore.properties— never commit the keystore or its passwords). Then get its SHA-256 fingerprint with./gradlew signingReportand set it asANDROID_CERT_FINGERPRINTSin the portal's production.env— without it, the emailed verification link's App Link won't verify and will just open in the browser instead of the app. - Confirm the release build type's
API_BASE_URL(https://astrocast.app) points at your actual deployed portal. - Build the release bundle:
producing
./gradlew bundleRelease
app/build/outputs/bundle/release/app-release.aab. - Upload the AAB to the Play Console for
pl.adamlange.astrocast(or your own application id, set inapp/build.gradle.kts), first to an internal testing track (the Play Console's equivalent of TestFlight), then promote to production once verified.
- Typing in the search box hits Laravel's own
/api/locations/search, which proxies Open-Meteo's geocoding API directly (no worker hop — it's a stateless, latency sensitive path). - Picking a result navigates to
/forecast?.... The controller asks the worker to resolve (get-or-create) that location in Postgres, then checks Postgres itself for freshness: ahourly_forecastsrow for the current UTC hour, updated within the last 90 minutes.- Warm (common case): reads straight from Postgres, no worker call — fast.
- Cold/stale: dispatches a queued job that asks the worker to refresh (fetch
the 3 weather APIs + compute astronomy + persist); the page renders immediately
in a "fetching…" state and polls
/forecast/{id}/dataevery ~1.5s until ready.
- The worker also runs a
BackgroundServicethat periodically re-refreshes every known location on the same staleness rule, independent of user requests.
Both mobile apps reach the same forecast data through /api/mobile/* (Bearer-token
authenticated, mobile:app + forecast:read abilities) rather than the browser-facing
Inertia routes — see ios/AstroCast/AstroCast/Networking/ForecastService.swift and
android/AstroCast/app/src/main/java/pl/adamlange/astrocast/network/ForecastService.kt.
backend/AstroCast.Worker/
Services/Providers/ Open-Meteo, 7Timer!, and MET Norway HTTP clients — all free,
keyless weather sources (unchanged from the original build)
Services/
ForecastAggregator resamples each provider onto one hourly UTC grid and averages
AstroCalculator deterministic sun/moon position, twilight, and moon-phase math
SkyQualityService samples a bundled light-pollution atlas image per coordinate
RefreshOrchestrator runs the above for one location and persists raw results
RefreshBackgroundService periodic PeriodicTimer refresh of stale locations
Data/ Dapper/Npgsql repositories (raw SQL upserts; no ORM/migrations
on this side — see "Schema ownership" below)
Endpoints/ Docker-internal only: /internal/locations/resolve,
/internal/refresh/{id}, /internal/health
webapp/ Laravel 13 + Inertia.js + Vue 3
app/Http/Controllers/ LocationSearchController, ForecastController, plus
Mobile* controllers backing /api/mobile/* for the mobile apps
app/Jobs/ RefreshLocationForecast (queued worker-refresh trigger)
app/Models/ Location, HourlyForecast, DarknessWindow, MoonInfo, SkyQuality,
GalleryPhoto, Equipment, FavoriteLocation
database/migrations/ Schema source of truth for every table, including the ones
the worker writes to (see below)
resources/js/
composables/ useRatings (ported RatingService.cs thresholds — Good/Ok/Bad
bands are computed client-side, not stored), usePhaseShading,
useFormatting
Components/ SearchBox, ForecastGrid (fit-to-width table + custom
scrollbar + phase-shaded header, ported from the original
vanilla-JS build), NightCard, AppHeader, Modal
Pages/ Home.vue, Forecast.vue, Gallery/Index.vue,
PublicProfile/Show.vue, and the admin pages
ios/AstroCast/AstroCast/ SwiftUI app, generated via XcodeGen (project.yml)
Networking/ APIClient (Bearer-token HTTP), ForecastService, GalleryModels,
SettingsModels, ProfileVisibilityModels
Security/ SessionStore (token persistence), KeychainStore, BiometricGate
(Face ID gate on app resume)
Location/ LocationManager, IPGeolocationService (fallback when the user
hasn't granted device location)
Features/ Auth, Home, Forecast, Locations, Gallery, Settings, Root
DesignSystem/ Starfield/ConstellationView backgrounds, OutlookDialView,
shared color/font tokens matching the web portal's dark theme
android/AstroCast/app/src/main/java/pl/adamlange/astrocast/
Kotlin + Jetpack Compose app, package-by-feature, mirroring
the iOS app's own structure 1:1 (no DI framework either — a
manual AppContainer singleton, matching SessionStore's role)
network/ ApiClient (Retrofit + OkHttp, Bearer-token interceptor),
ForecastService, GalleryModels, SettingsModels
security/ SessionViewModel (token persistence via EncryptedSharedPreferences),
TokenStore, BiometricGate (BiometricPrompt on app resume)
location/ DeviceLocationManager, IpGeolocationService (fallback when the
user hasn't granted device location)
auth/, home/, forecast/, locations/, gallery/, settings/, root/
One package per feature, same split as iOS's Features/
designsystem/ Starfield/ConstellationView Canvas backgrounds, OutlookDialView,
Ratings.kt (byte-for-byte port of the same thresholds), shared
color/font tokens matching the web portal's dark theme
Laravel migrations are the single source of truth for every table, including
locations, hourly_forecasts, darkness_windows, moon_info, and sky_quality
which the C# worker writes to. The worker talks to the same Postgres via Dapper/Npgsql
with hand-written SQL (including ON CONFLICT upserts) — no EF Core, no second
migration system. Extending the schema means adding a Laravel migration; the worker's
repositories just need matching column names.
hourly_forecasts is upserted per (location_id, time_utc): future hours get
overwritten as forecasts improve, and past hours simply stop being touched — that's
the historical record, with no separate archive table. darkness_windows/moon_info
are deterministic astronomy, so they're computed once per (location_id, date) and
never recomputed.
Clear Outside is powered by a paid, proprietary weather provider (Meteosource,
formerly Dark Sky/Forecast.io) whose exact cloud-cover/seeing algorithm is not
published. This project instead blends three free, open sources and applies its own
Good/Ok/Bad thresholds — see webapp/resources/js/composables/useRatings.js — so the
shape of the forecast is comparable but the exact numbers won't line up. A direct
comparison against live Clear Outside data (pressure/temperature/darkness/moon match
closely; cloud cover diverges as expected between different models) is summarized in
project history.
Two things do match any correct reference exactly, since they're deterministic
astronomy rather than weather-model output: civil/nautical/astronomical twilight times
and moon phase/rise/set (backend/AstroCast.Worker/Services/AstroCalculator.cs).
Sky quality is a rough, clearly-labeled approximation derived from a static 2024
light-pollution atlas (see backend/AstroCast.Worker/assets/lightpollution/README.md
for provenance and licensing notes) — not real-time data, and not the atlas author's
own Bortle metric (he explicitly asks that the two not be conflated).
Breeze auth (login/register/profile) is wired into the header — "Log in"/"Register"
when signed out, name + "Settings" + "Log out" when signed in — and restyled to match
the app instead of Breeze's default light theme. The same account system authenticates
both mobile apps via a separate Sanctum ability (mobile:app) rather than session cookies.
An admin account is seeded on deploy (database/seeders/AdminUserSeeder.php) from the
ADMIN_EMAIL / ADMIN_PASSWORD environment variables — idempotent via firstOrCreate,
so it never overwrites an existing user's password, and is_admin is only granted at
first creation. If either variable is unset, the seeder logs a warning and skips
creating an admin account. Admin-ness is a single users.is_admin
boolean, enforced by the admin middleware alias
(app/Http/Middleware/EnsureUserIsAdmin.php) on every /admin/* route.
Signed-in admins see an "Admin" link in the header, leading to:
/admin/users— list every account, promote/demote admin status, delete users (both blocked against targeting your own account)./admin/status— live health for all three backend services: Postgres (connection + response time + row counts), the C# worker (/internal/health+ time since its last successful write tohourly_forecasts), and the queue worker (pending/failed job counts + a cache-based heartbeat written byRefreshLocationForecaston every run, since there's no direct way to HTTP-ping a long-runningartisan queue:workprocess).
Email verification and clear-sky alert notifications need real outgoing mail. By
default, MAIL_MAILER=smtp with placeholder host/from-address values (.env at the
repo root, plus webapp/.env/.env.example) — sends will simply fail (logged as a
warning, not fatal to the app) until you fill in a real provider's
MAIL_HOST/MAIL_PORT/MAIL_USERNAME/MAIL_PASSWORD/MAIL_FROM_ADDRESS. For Docker,
set these in the repo-root .env (picked up by docker-compose.yml's laravel_env
anchor); for local (non-Docker) dev, set them directly in webapp/.env.
If your provider's mail box is shared/reseller hosting, its TLS certificate may not
match MAIL_HOST (e.g. it presents the box's own hostname instead) — sends will fail
with a did not match expected CN error even with correct credentials. Set
MAIL_TLS_PEER_NAME to whatever hostname the certificate is actually issued for, and
config('mail.mailers.smtp.tls_peer_name') (wired up in AppServiceProvider) pins TLS
verification to that name instead of disabling verification altogether.
- Observation session log: let users log their own imaging sessions (date,
location, notes, equipment, rating) against the historical weather data already
sitting in
hourly_forecasts/darkness_windows/moon_info. - iOS CI: wire
xcodebuild testinto.github/workflows/ci.ymlalongside the existing portal and Android test jobs, so mobile regressions are caught the same way. - Android instrumented UI tests in CI:
android-testscurrently only runs the JVM-only unit tests; runningconnectedAndroidTestwould need an emulator (or Firebase Test Lab) plus a live backend service in the CI job.
