Interactive 3D visualization of Earth's day/night cycle and seasons. Built as a Progressive Web App for classroom use with kids ages 8+. This app is suitably accurate for classroom study of the natural effect of Earth's axial tilt (obliquity). It will become inaccurate in simulations exceeding +-200 years.
- 3D globe and synchronized equirectangular projection showing the day/night terminator, computed from the sun's position using Jean Meeus algorithms
- Includes time controls (smooth sweep and day-snap modes)
- Geographic overlays (coastlines, rivers, lakes)
- Latitude reference lines (equator, tropics, arctic circles) & longitude reference lines
- Per-location sun data (sunrise, sunset, elevation, azimuth)
- Allows changing the Earth's axial tilt from zero to ninety degrees
Use these instructions if this is a new install of the application.
git clone https://github.com/fatcat/ephemeris.git
cd ephemeris
docker compose --buildgit clone https://github.com/fatcat/ephemeris.git
docker build -t ephemeris .After the initial "clone" is performed, the app must be rebuilt if an update from the repo is made, or if any local changes to the code or container configuration are made.
git pull
docker compose build --no-cacheBefore starting the app, SSL certificates must be installed in ./ephemeris/certs. If this is a new clone of the repo, see the section on certificates below. If this is an existing install you should already have certificates in place. Pulling repo updates or performing a docker build does not alter the certificates.
# With Docker Compose
docker compose up -d
# With Docker
docker run -p 8181:8080 -p 8443:8443 -v ./certs:/certs:ro ephemeris# With Docker Compose
docker compose down
# With Docker
docker <container id> downAfter bringing up the container the app will be running at https://x.x.x.x:8443 (HTTP on port 8181 redirects to HTTPS). These ports can be changed by editing docker-compose.yml and running docker compose up -d.
Ephemeris requires HTTPS because the service worker (needed for offline/PWA support) only registers over secure connections. You have two options: self-signed certificates (simpler, good for local/classroom use) or publicly signed certificates (needed if the app is exposed to the internet with a domain name).
The included generate-certs.sh script creates a local Certificate Authority (CA) and a server certificate signed by it:
./generate-certs.sh [hostname] # hostname defaults to "ephemeris"This creates three files in ./certs/:
ca.crt— the CA certificate (install this on client devices)server.crt— the server certificateserver.key— the server private key
The container also serves the CA certificate at https://<host>/ca.crt for easy download.
After generating certs, you must install ca.crt on each device that will access the app so browsers trust the HTTPS connection.
- Download or copy
ca.crtto the device (or navigate tohttps://<host>/ca.crt) - Double-click the
.crtfile - Click Install Certificate...
- Select Local Machine, click Next
- Select Place all certificates in the following store, click Browse
- Choose Trusted Root Certification Authorities, click OK
- Click Next, then Finish
- Restart your browser
Alternatively, an administrator can deploy the certificate via Group Policy. See Microsoft's documentation on distributing certificates using Group Policy.
- Download or copy
ca.crtto the device (or navigate tohttps://<host>/ca.crt) - Double-click the
.crtfile — this opens Keychain Access - When prompted, add it to the System keychain (or login for single-user)
- Find the certificate in Keychain Access (search for "Ephemeris Local CA")
- Double-click it, expand Trust, and set When using this certificate to Always Trust
- Close the window and enter your password to confirm
- Restart your browser
See Apple's documentation on installing a CA certificate on Mac.
- Open Settings > Security and Privacy > More > Manage certificates
- Go to the Authorities tab and click Import
- Select the
ca.crtfile and check Trust this certificate for identifying websites - Click OK and restart Chrome
- Navigate to
https://<host>/ca.crtin Safari and tap Allow to download the profile - Open Settings > General > VPN & Device Management and install the downloaded profile
- Go to Settings > General > About > Certificate Trust Settings and enable full trust for "Ephemeris Local CA"
- Navigate to
https://<host>/ca.crtin Chrome - When prompted, name the certificate and select Wi-Fi as the credential use
- Confirm installation (you may need to set a screen lock if one isn't configured)
If you have a domain name and want real certificates (no CA installation needed on clients), replace the self-signed certs with ones from a public CA like Let's Encrypt.
- Obtain a certificate and key for your domain. With certbot:
sudo certbot certonly --standalone -d yourdomain.example.com
- Copy the certificate and key into the
./certs/directory:cp /etc/letsencrypt/live/yourdomain.example.com/fullchain.pem ./certs/server.crt cp /etc/letsencrypt/live/yourdomain.example.com/privkey.pem ./certs/server.key
- Start or restart the container:
docker compose up -d
No ca.crt is needed — browsers already trust Let's Encrypt. You will need to renew certificates before they expire (every 90 days). See the Certbot documentation for automated renewal options.
If you are behind a reverse proxy (e.g. Traefik, Caddy, or an institutional load balancer) that already terminates TLS, you can skip the certificate setup entirely and proxy directly to the container's HTTP port (8080).
Ephemeris ships with two layers of defense against internet vuln-scanners, the bots that probe for /wp-admin, /.env, /phpmyadmin and the like. They stack, and you can run either one on its own.
- njs (built in, on by default). A small script inside nginx watches for known scanner paths. After a few hits from the same IP it blocks that IP on every path for a while. This runs inside the container, so a fresh
docker runis already protected with no host setup. - fail2ban (optional, host level). Adds a durable kernel-level ban that drops a bad IP at the firewall before it ever reaches nginx. It survives container restarts and needs a little host setup.
Running both is the recommended setup. njs responds instantly and works anywhere. fail2ban escalates to a firewall drop and remembers bans across restarts.
Settings live in njs/ban-config.json:
{
"enabled": true,
"maxBadRequests": 3,
"findtimeSeconds": 600,
"banTimeSeconds": 86400
}enabled: set tofalseto turn njs banning off, for example if you would rather let fail2ban do all the banning. Scanner paths still get closed, njs just stops banning IPs.maxBadRequests: how many scanner hits trigger a ban. Default 3.findtimeSeconds: the window those hits must fall within. Default 600 (10 minutes).banTimeSeconds: how long a ban lasts. Default 86400 (24 hours).
The file is baked into the image with these defaults. To change values without rebuilding, docker-compose.yml bind-mounts it. Edit ./njs/ban-config.json on the host and reload nginx:
docker compose exec ephemeris nginx -s reloadWith plain docker run, add -v ./njs/ban-config.json:/etc/nginx/njs/ban-config.json:ro to tune it, or just rebuild after editing.
Worth knowing:
- njs bans at the HTTP layer. The connection is accepted and then rejected. fail2ban is the layer that drops traffic at the firewall.
- njs ban state lives in memory. A container restart clears current njs bans. fail2ban bans persist.
- Banning counts bad paths, not traffic volume. A classroom behind one shared IP never trips it, because real users never request scanner paths.
- njs bans by the client IP nginx sees. Read the real client IP note below.
The fail2ban/ folder has everything needed. It bans a scanner IP at the host firewall after 3 bad requests in 10 minutes, for 24 hours.
cd fail2ban
sudo ./setup.sh install # install configs, start the jail, arm a safety timer
sudo ./setup.sh status # show current bans
sudo ./setup.sh rollback # undo everythingsetup.sh install arms a dead man's switch. If you get locked out it auto-undoes the setup after 30 minutes. Once you confirm you still have access, cancel it:
sudo ./setup.sh cancel-deadmanRequirements:
fail2ban,nftables, andatinstalled on the host.- An existing nftables
inet firewalltable with afail2banset. The action adds banned IPs to it, and the firewall checks it for both direct and container-forwarded traffic so Docker-proxied requests are dropped too. - The host log dir
/var/log/ephemerismust be writable by the container's nginx user. The container writes its access log there and fail2ban reads it. If ownership is wrong, nginx cannot write the log, the log stays empty, and nothing ever bans. Chown it to the nginx uid (usually 101) and usecopytruncatein logrotate so rotation does not cut the log feed.
Both njs and fail2ban ban the IP that nginx sees as the client. If the container runs behind Docker's default NAT with userland-proxy on, nginx can see the bridge gateway (something like 172.17.0.1) for every request. Then a ban either targets the gateway and blocks everyone, or never lands on a real attacker.
Check what nginx actually logs:
awk '{print $1}' /var/log/ephemeris/access.log | sort | uniq -c | sort -rn | headReal public IPs mean you are set. One 172.x address for everything means you must fix this before relying on either ban layer. Set userland-proxy: false in the Docker daemon config, or use host networking. If a reverse proxy sits in front, forward the real client IP and configure nginx real_ip so $remote_addr is the true client.
- Svelte 5 (runes mode) + TypeScript
- Three.js (WebGL)
- Vite + vite-plugin-pwa
npm install
npm run dev # dev server with HMR
npm run build # production build
npm run check # type checking (svelte-check)
npm run lint # eslintThe Dockerfile uses a two-stage build:
- Build stage (
node:22-alpine) — installs npm dependencies frompackage-lock.jsonand runsnpm run buildto produce static files indist/ - Serve stage (
nginx:alpine) — serves the static output over HTTPS with TLS 1.2+, security headers, gzip compression, SPA fallback routing, and aggressive caching for Vite-hashed assets. Runs as the unprivilegednginxuser (no root). Includes the njs module, which powers the built-in scanner banner (see Scanner protection)
Final image size is ~63 MB.
Some bundled data ages over time, albeit slowly.
- Timezone boundaries (
@photostructure/tz-lookup) — IANA timezone data updates a few times per year as countries adjust timezone rules - npm packages — periodic security patches
Updating containers once or twice a year is plenty to keep things current. To update:
npm update # update packages within semver ranges
npm run build # verify the build still passes
npm run check # verify types
docker build -t ephemeris . # rebuild the imageNo external data is fetched at runtime. All textures, timezone data, and solar algorithms are bundled at build time. Rebuilding the image a couple of times per year is sufficient to stay current.
Code is MIT. All textures and geographic data are public domain (Natural Earth, NASA).
