GrowCast is a Next.js web app for a live garden: climate, energy, gallery, an OBS overlay, and Twitch broadcast, with a protected admin panel.
GrowCast lets you share your grow in real time. Visitors see the live stream, climate, energy, and media. Admin users update the grow from the settings pages.
- Live stream embed on the homepage (an RTSP camera through MediaMTX, RTSP to HLS)
- Public grow dashboard, gallery, and energy
- Live tent climate from the Spider Farmer sidecar
- Timelapse snapshots and video
- OBS overlay at
/overlayand the Broadcast page (/admin/stream) for music, alerts, Twitch, and camera look - Settings bands with in-page save
To see a live demo, visit my instance. The official project site is growcast.0xmarcel.com.
- Docker Engine and the Docker Compose plugin
- Node.js 20 LTS or newer, and npm, when you want
npm run devornpm run setup:admin
A camera, MediaMTX, and Cloudflare are for publishing a stream. They are not required to open the site.
Clone the repository and start the stack:
docker compose up --build -dThat builds and starts the website, the climate sidecar, the Twitch restream image, and the timelapse worker. The first build downloads Chromium for the restream image and can take a while.
Open http://localhost:3000. Until an admin account exists, the site opens the setup wizard. The container log prints a setup code (docker compose logs growcast). Enter that code on the first step, then create the admin login. The wizard also asks which sidecars to configure:
- Climate: Spider Farmer email and password. One controller is saved automatically. Several controllers ask you to choose.
- Twitch: stream key and channel. Start stays on Broadcast.
- Timelapse: camera RTSP URL, interval, and timezone.
The mesh token is generated on first boot and shared with the sidecars. You do not paste it.
npm run setup:admin still writes .env.local for an admin account created outside the wizard. Passwords must be at least 12 characters. For local/dev only:
npm run setup:admin:insecure
# or: npm run setup:admin -- --allow-insecure(Do not use npm run setup:admin --allow-insecure — npm treats that as its own config, not a script argument.)
An account already present in .env.local skips the wizard.
.env.local is optional. Compose loads it when the file exists (env_file required: false).
ADMIN_USERNAME=your_admin_username
ADMIN_PASSWORD_HASH=scrypt$...$...
ADMIN_SESSION_SECRET=at_least_32_chars_random_secret
GROWCAST_MESH_TOKEN=optional_existing_tokenNotes:
ADMIN_PASSWORD_HASHmust use thescrypt$...format.ADMIN_SESSION_SECRETmust be at least 32 characters.- When
GROWCAST_MESH_TOKENis set, that value is copied todata/mesh.tokenand the sidecars use it. When it is unset, the container creates one token on first boot. - Admin passwords must be at least 12 characters (
npm run setup:adminand the wizard enforce this). For local/dev only, usenpm run setup:admin:insecure(ornpm run setup:admin -- --allow-insecure). - Admin sessions last 24 hours and live in memory.
docker compose up --buildrecreates the website container and signs you out. Sign in again. - Optional, Broadcast channel lookup and Twitch EventSub alerts (GrowCast
.env.local, not the restream sidecar):TWITCH_CLIENT_IDandTWITCH_CLIENT_SECRET. EventSub webhooks also needGROWCAST_PUBLIC_URL(the public HTTPS origin, e.g. the Cloudflare Tunnel hostname).
GrowCast writes human-readable logs to stdout so docker compose logs shows the setup code, auth events, and request lines without a JSON parser. Set LOG_FORMAT=json for one JSON object per line (log shipping). Correlation IDs are set in the Next.js proxy (X-Request-ID on responses). Optional env vars: LOG_LEVEL, LOG_FORMAT, GROWCAST_ENV.
Full schema, event catalog, redaction rules, Docker log shipping, retention guidance, and alert examples: docs/logging.md.
docker compose up --build -d is the supported start command. It runs four services from docker-compose.yml: growcast, ggs, restream, and timelapse.
For production, put the origin behind a Cloudflare Tunnel (HTTPS public hostname → http://127.0.0.1:3000). Compose publishes on ${GROWCAST_BIND:-127.0.0.1}:${GROWCAST_PORT:-3000} (loopback unless you set GROWCAST_BIND=0.0.0.0 for a LAN) and sets GROWCAST_TRUST_PROXY=1. Login rate-limits use CF-Connecting-IP, then X-Real-IP, then the first X-Forwarded-For hop, which Caddy sends by default. Those headers are ignored unless the flag is set. Admin cookies are Secure when X-Forwarded-Proto: https or CF-Connecting-IP is present (or COOKIE_SECURE=1). Direct HTTP to a public :3000 is not the supported admin path.
Local-only UI: http://localhost:3000 (session cookie is not Secure).
Useful commands:
docker compose up --build -d
docker compose logs -f growcast
docker compose downdocker compose up --build -d recreates the website container and signs you out. Sessions stay in memory for 24 hours or until logout. Sign in again.
What gets persisted on the host:
./data->/app/data(grow data, mesh token, sidecar env files, restream state)./extensions/GrowCast-Timelapse->/app/extensions/GrowCast-Timelapse(snapshots and timelapse video)./public/setup->/app/public/setup./public/yourPictures->/app/public/yourPictures
The website writes data/ggs.env for the climate sidecar and data/timelapse.env for the camera URL. Those files live on the ./data directory mount, so Docker does not create a directory in place of a missing env file. The climate and timelapse containers read those files and reload their processes when the files change.
Sidecars without credentials wait, then start when the wizard (or admin settings) writes the config. Twitch restream stays idle until you save a stream key and press Start on Broadcast (/admin/stream). GrowCast writes data/restream/capture.token on its own; GROWCAST_RESTREAM_TOKEN in .env.local is an optional override.
Broadcast (/admin/stream) previews the 1920×1080 program with background music (uploaded playlist or a stream URL; URL wins while set) and on-stream alerts (manual Send alert, plus Twitch follow/sub/raid/bits after Connect Twitch). Public /overlay and the homepage stay silent.
The website container runs as uid 1001 (growcast). The entrypoint chowns ./data, the upload folders, and the timelapse snapshots and timelapse directories so the process can write them. It does not change ownership of the plugin source, so a later git pull still works. After the first run those data folders are owned by 1001:1001 on the host. The climate and timelapse sidecars use the same uid so they can read ./data.
Optional address:
- The compose file publishes
${GROWCAST_BIND:-127.0.0.1}:${GROWCAST_PORT:-3000}:3000. - Set
GROWCAST_PORTfor a different port, orGROWCAST_BIND=0.0.0.0to reach the site from other machines on the LAN.
MediaMTX stays outside this compose file. .env.local, media folders, and data/ are provided at runtime and are not baked into the image.
npm run devOpen http://localhost:3000.
npm run build
npm run startThis starts the standard Next.js production server. The Docker image builds a standalone bundle automatically during docker compose build.
app/
(site)/page.tsx # Public dashboard
(site)/gallery/page.tsx # Gallery
(site)/energy/page.tsx # Energy
overlay/page.tsx # OBS overlay
setup/page.tsx # First-run wizard
admin/page.tsx # Admin login + grow settings
admin/stream/page.tsx # Broadcast
admin/ggs/page.tsx # Climate and energy settings
admin/timelapse/page.tsx # Timelapse settings
extensions/
GrowCast-GGS/ # Climate sidecar
GrowCast-Restream/ # Twitch restream sidecar
GrowCast-Timelapse/ # Timelapse sidecar and media
data/
mesh.token # Shared sidecar token, created on first boot
ggs.env # Climate credentials written by the wizard
timelapse.env # Camera URL written by the wizard
The homepage player uses a browser HLS URL. MediaMTX converts the camera RTSP stream. These settings avoided stutter on iOS and some Windows players:
hlsAlwaysRemux: true
hlsVariant: fmp4
hlsSegmentCount: 7
hlsSegmentDuration: 1s
hlsPartDuration: 200ms
hlsSegmentMaxSize: 50M
hlsDirectory: ''
hlsMuxerCloseAfter: 60s
hlsAllowOrigin: '*'
paths:
growcam:
source: rtsp://USER:PASSWORD@IP.OF.YOUR.CAM/stream1
sourceProtocol: tcp
sourceOnDemand: no
If playback still stutters, lower the camera frame rate. 15 fps is a reasonable start.
A public stream needs a second Cloudflare hostname for MediaMTX HLS, separate from the GrowCast hostname. Point Broadcast's stream URL at that public HLS path, for example https://stream.example.com/growcam/.
The timelapse sidecar uses the camera's RTSP URL directly. That URL is collected in the setup wizard and stored in data/timelapse.env.
GrowCast expects a browser-playable stream URL in the admin dashboard. Since some cameras expose RTSP, use MediaMTX to convert RTSP to HLS:
- Configure your RTSP camera (RTSP source looks somewhat like this:
rtsp://<camera-ip>:554/<path>). - Run MediaMTX and create a path that ingests RTSP.
- Use MediaMTX HLS output URL as the stream URL on Broadcast (
/admin/stream), for example:http://<mediamtx-host>:8888/<path>/
- Save on the Broadcast page.
This app uses Next.js route handlers and local filesystem storage.
- Primary source:
data/current-grow.json - Read/write logic:
lib/db.ts - If file is missing, default data is generated.
GET /api/data/current-grow- Returns the normalized grow record as JSON
- Uses
Cache-Control: no-store, must-revalidate
GET /api/snapshots/[filename]- Serves image files from
extensions/GrowCast-Timelapse/snapshots
- Serves image files from
GET /api/timelapse- Serves timelapse video from
extensions/GrowCast-Timelapse/timelapse/latest_timelapse.mp4
- Serves timelapse video from
GET /api/data/live-climate- Public latest GGS climate JSON (no credentials)
Cache-Control: no-store
GET /api/data/broadcast{ "live": true, "login" }while the homepage Twitch toast may show; otherwise{ "live": false }Cache-Control: no-store, must-revalidate
GET /api/data/live-climate/stream- Public SSE; snapshot on change + heartbeat every 15s
- Reverse proxy must not buffer this path
(
nginx:location /api/data/live-climate/stream { proxy_buffering off; proxy_http_version 1.1; }, Caddy:flush_interval -1)
GET /api/mesh/[pluginId]- Returns registered plugin settings, for example
/api/mesh/growcast.timelapse - Requires the mesh token from the environment or
data/mesh.token, and a matchingAuthorization: Bearer <token>(fail-closed when both are unset)
- Returns registered plugin settings, for example
POST /api/mesh/growcast.ggs/state- Sidecar ingest, Bearer
GROWCAST_MESH_TOKEN
- Sidecar ingest, Bearer
- Username + scrypt password hash from
.env.localor from the setup wizard (data/setup/admin.json) - Default
setup:adminand the wizard require a 12-character password;--allow-insecureis local/dev only - Login verifies the stored scrypt hash (non-empty + max length); it does not re-apply the 12-character setup minimum
- Signed cookie-based sessions (24-hour TTL)
- Optional authenticator (TOTP) and one-time recovery codes in
data/setup/totp.json, turned on from Admin → Security. Sign-in stays password-only until that file is confirmed. - If the phone and the recovery codes are both lost, stop GrowCast and delete
data/setup/totp.json. The next sign-in is password-only. RotatingADMIN_SESSION_SECRETalso makes that file unreadable; delete it and turn the authenticator on again. - In-memory session store (single-node deploy). Recreating the container with
docker compose up --buildsigns you out.
To make the HLS source and app publicly accessible without exposing your home network, publish both services through Cloudflare Tunnel:
- Run GrowCast (example:
http://localhost:3000). - Run MediaMTX (example: HLS endpoint on
http://localhost:8888). - Create tunnel routes with
cloudflared:- One public hostname for GrowCast (example:
growcast.example.com->http://localhost:3000) - One public hostname for MediaMTX HLS (example:
stream.example.com->http://localhost:8888)
- One public hostname for GrowCast (example:
- In GrowCast admin, set
Stream URLto your public MediaMTX HLS URL:https://stream.example.com/<path>/
- Verify both endpoints are reachable through Cloudflare.
Important:
- Keep admin credentials strong (
ADMIN_*env vars).
Cause:
- Missing/invalid
ADMIN_*env variables.
Fix:
- Run
npm run setup:adminand restart the app.
Cause:
extensions/GrowCast-Timelapsefolder missing or no media generated.
Fix:
- Install/run the timelapse plugin and ensure snapshots/timelapse files exist.
Cause:
- Stale page cache after edits.
Fix:
- Restart dev server.
