# ── instagram-scheduler — environment ──────────────────────────────────────── # Copy to .env and fill in. Read by both `node server.js` and Docker Compose. # # Almost everything here is optional and has a sensible default. The one that # genuinely matters is PUBLIC_ORIGIN. # # 🔑 Meta credentials are deliberately NOT in this file. # # They live in the database, one independent set per Instagram account, because # the access token is refreshed automatically every ~60 days — the moment that # happens, any value in a file is stale. Add accounts in the panel # (Accounts → Add account), and use the built-in guide at /guide to obtain them: # it walks Meta's console step by step and pastes each value straight onto the # account record. # ═══════════════════════════════════════════════════════════════════════════ # THE ONE THAT MATTERS # ═══════════════════════════════════════════════════════════════════════════ # Instagram DOWNLOADS your videos from this app. It does not accept uploads. # # So this must be the publicly reachable HTTPS address of this app, with no # trailing slash. If it is wrong, unreachable, or sitting behind a bot filter, # every publish fails with a container error Instagram will not explain. # # A localhost address cannot work — Instagram is not on your machine. # # Diagnostics → "Run media self-test" proves the whole path end to end by # fetching your real URL with Instagram's own user-agent. Run it before your # first publish, not after it fails. PUBLIC_ORIGIN=https://scheduler.example.com # ═══════════════════════════════════════════════════════════════════════════ # TIME # ═══════════════════════════════════════════════════════════════════════════ # IANA zone used for schedules, posting windows and every timestamp in the panel. # This SEEDS the first run only — afterwards the value in Settings wins, so # changing it here later has no effect. TIMEZONE=UTC # The container's own clock, for log timestamps. Different setting, different # purpose; people mix these two up constantly. TZ=UTC # ═══════════════════════════════════════════════════════════════════════════ # SERVER # ═══════════════════════════════════════════════════════════════════════════ PORT=4300 # Bind address. Unset listens on everything. Docker sets 0.0.0.0 in the image. # HOST=127.0.0.1 # Set to 1 when a reverse proxy terminates TLS in front of this app, so req.ip # and the secure-cookie decision read X-Forwarded-* rather than seeing the proxy. # Without it, a login over HTTPS-through-a-proxy can silently fail to set a # cookie, which presents as "signing in does nothing". TRUST_PROXY=0 # ═══════════════════════════════════════════════════════════════════════════ # DOCKER # ═══════════════════════════════════════════════════════════════════════════ # Only read by compose.yaml. Ignored when running with plain node. # Where your data lives on the HOST. Defaults to ./data — an ordinary folder you # can browse, rsync and back up without Docker. # # It must be writable by uid 1000 (the container's user): # mkdir -p ./data && sudo chown -R 1000:1000 ./data # # The entrypoint checks this and tells you exactly what to run if it is not. DATA_DIR=./data # Host interface and port. 127.0.0.1 keeps the app off the public internet when a # reverse proxy on the same host is fronting it — which it should be, given this # app holds Instagram credentials. BIND_ADDRESS=127.0.0.1 HOST_PORT=4300 # CONTAINER_NAME=instagram-scheduler # IMAGE=ghcr.io/anefzaoui/instagram-scheduler:latest # NODE_VERSION=24 # RESTART_POLICY=unless-stopped # ffmpeg uses every core it is given. Capping CPU keeps a long transcode from # starving the rest of a small VPS; the encode just takes longer. CPU_LIMIT=2.0 MEMORY_LIMIT=2g MEMORY_RESERVATION=256m # Log rotation. Without this a long-running container fills the disk. LOG_MAX_SIZE=10m LOG_MAX_FILE=3 # Used only by compose.caddy.yaml, which adds automatic HTTPS. # DOMAIN=scheduler.example.com # LETSENCRYPT_EMAIL=you@example.com # ═══════════════════════════════════════════════════════════════════════════ # STORAGE PATHS # ═══════════════════════════════════════════════════════════════════════════ # Relative paths resolve against the project root, not the working directory. # Under Docker these point inside the container — change DATA_DIR instead. # DB_PATH=./data/scheduler.db # LIBRARY_DIR=./data/library # RENDERS_DIR=./data/renders # ═══════════════════════════════════════════════════════════════════════════ # ffmpeg # ═══════════════════════════════════════════════════════════════════════════ # Resolution order: FFMPEG_PATH → `ffmpeg` on PATH → vendor/ffmpeg/ → nothing. # `npm run ffmpeg:fetch` populates vendor/ for your OS and architecture. # The Docker image installs ffmpeg itself, so neither is needed there. # FFMPEG_PATH=/usr/bin/ffmpeg # FFPROBE_PATH=/usr/bin/ffprobe # ═══════════════════════════════════════════════════════════════════════════ # UPLOADS # ═══════════════════════════════════════════════════════════════════════════ # Instagram's own ceiling is 300MB for video and 8MB for images, so a higher # value here only helps if you intend to transcode down before publishing. MAX_UPLOAD_MB=500 # ═══════════════════════════════════════════════════════════════════════════ # WORKER # ═══════════════════════════════════════════════════════════════════════════ # 0 makes this a panel-only process. If you run several replicas, exactly ONE may # have the worker enabled — otherwise they race to advance the same jobs and each # spends its own Instagram API budget doing it. WORKER_ENABLED=1 # How often the worker looks for work. Lower is not better: the useful work is # gated by Instagram's transcode time and by rate limits, not by this. TICK_SECONDS=10 # How many times a transient failure is retried before the job is marked failed. # Auth and media-rejection errors ignore this and fail immediately, because they # never resolve themselves. Attempts are spaced 1min / 5min / 15min apart, so three # attempts span about twenty minutes rather than thirty seconds — a fault that # clears in the meantime no longer costs you the post. # # HOST faults (ffmpeg missing or crashing) do not consume attempts at all. They are # not the job's fault and they fail every job identically, so they are handled by # the render pause below instead. MAX_ATTEMPTS=3 # Consecutive host faults before the render stage stops altogether. # # A crashing or missing ffmpeg fails every post in the queue, so grinding through # them one at a time discovers nothing and costs a post per attempt cycle. Pausing # turns "the day is lost" into "a few posts are late", and puts a named cause on the # timeline instead of a wall of identical errors. Rendering resumes by itself within # a minute of ffmpeg working again; publishing of already-rendered posts never stops. RENDER_FAULT_PAUSE_AFTER=3 # How far past its slot a queued post may drift before it stops being "late". # # Posts stuck behind a render pause are re-queued automatically when the host # recovers — but only for this long. Past it they are failed and offered on the # timeline's "Recover all" banner instead, so a day-old schedule is a human's # decision rather than a surprise burst of stale posts. STALE_SLOT_HOURS=2 # ═══════════════════════════════════════════════════════════════════════════ # INSTAGRAM RATE LIMITING # ═══════════════════════════════════════════════════════════════════════════ # Read docs/architecture.md §4 before changing these. They were arrived at by # having real posts killed by Instagram's error code 4. # Instagram's platform limit is 200 API calls per hour per account. This counter # is OURS — Instagram's own usage headers report zero regardless of load on this # API, so a header-driven brake never fires. Stopping below the real limit is the # entire mechanism, so raising this towards 200 defeats it. Lower it if another # tool shares the same account. Capped at 190 in code. IG_CALL_BUDGET_PER_HOUR=150 # Each open container costs about one poll per minute. More concurrency means more # polling against the same budget, so raising this without raising the budget is # how you re-earn a lockout. IG_MAX_OPEN_CONTAINERS=3 # ═══════════════════════════════════════════════════════════════════════════ # SESSIONS # ═══════════════════════════════════════════════════════════════════════════ # How long a panel login lasts. Shorten it on a shared machine. SESSION_TTL_DAYS=30 # ═══════════════════════════════════════════════════════════════════════════ # FIRST-RUN ADMIN (optional) # ═══════════════════════════════════════════════════════════════════════════ # If both are set AND no user exists yet, this account is created on boot — handy # for unattended or containerized deploys. # # They cannot reset an existing password: the check is "no users at all", so # leaving them in place after the first run does nothing. Otherwise use the # onboarding wizard at /setup, or `npm run seed:admin`. # # There is no public signup, ever. # ADMIN_USERNAME= # ADMIN_PASSWORD= # ═══════════════════════════════════════════════════════════════════════════ # TESTING # ═══════════════════════════════════════════════════════════════════════════ # Points the Meta API at a different host, so the whole publish flow can be # exercised against a stand-in without touching a real profile. Leave unset in # production — nothing in the app sets it, and pointing it elsewhere is an # explicit choice. # IG_API_HOST=http://127.0.0.1:4397