Fixed standalone Docker data persistence

Necessary to resolve issues where schedules and output were written
to /app/data inside the container instead of the user-mounted /data
volume. The app now auto-detects the data directory at startup and
supports both mount points:

- -v ./trmnl-data:/data     (recommended, matches docs)
- -v ./trmnl-data:/app/data (legacy workaround, also works)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ikraam Ghoor
2026-03-06 12:59:32 +00:00
co-authored by Claude Opus 4.6
parent bdb05afd55
commit dc8a0e903f
8 changed files with 90 additions and 16 deletions
+2
View File
@@ -51,6 +51,8 @@ docker run -d --name trmnl-ha \
```
> **Note:** Replace `YOUR_HOST_IP` with your machine's IP (e.g., `192.168.1.100`). Container names like `homeassistant` won't work since HA uses host networking.
>
> **Volume mount:** `-v ./trmnl-data:/data` is recommended. `-v ./trmnl-data:/app/data` also works.
Then open `http://localhost:10000` - that's it!
+12 -1
View File
@@ -91,6 +91,17 @@ docker run -d --name trmnl-ha \
ghcr.io/usetrmnl/trmnl-ha-amd64:latest
```
**Data Persistence:**
Schedules and output screenshots are stored in a persistent data directory. Two volume mount options are supported:
| Mount | Example | Notes |
|-------|---------|-------|
| `/data` (recommended) | `-v ./trmnl-data:/data` | Matches docs, used by HA add-on |
| `/app/data` (also works) | `-v ./trmnl-data:/app/data` | Legacy alternative |
Both options work — the app auto-detects which path is available. If you're already using `/app/data`, there's no need to change.
**Environment Variables:**
| Variable | Required | Description |
@@ -304,7 +315,7 @@ curl "http://192.168.1.x:10000/lovelace/0?viewport=800x480&dithering&palette=bw&
Create cron-based schedules via the Web UI for automatic captures.
**Storage:** `/data/schedules.json` (persists across restarts)
**Storage:** `schedules.json` in the data directory (persists across restarts when a volume is mounted — see [Data Persistence](#home-assistant-container-docker))
**Manual Trigger:** Click **Send Now** to execute immediately.
+10
View File
@@ -43,6 +43,7 @@ import {
isValidTimezone,
hasEnvConfig as checkEnvConfig,
detectIsAddOn,
detectDataDir,
findBrowser,
isNetworkError,
parseOptionsFile,
@@ -196,6 +197,15 @@ if (isAddOn) {
console.log('[Config] Running in standalone mode')
}
/**
* Persistent data directory for schedules, output screenshots, etc.
* - HA add-on: /data (mounted by HA Supervisor)
* - Standalone Docker: /data (user-mounted volume)
* - Local dev: ./data (relative to cwd)
*/
export const DATA_DIR: string = detectDataDir(isAddOn, process.cwd())
console.log(`[Config] Data directory: ${DATA_DIR}`)
/**
* Whether to use mock Home Assistant for testing and local development
* Set MOCK_HA=true environment variable to enable mock mode
+20
View File
@@ -119,6 +119,26 @@ export function detectIsAddOn(
)
}
/**
* Determine the data directory for persistence (schedules, output, etc.)
*
* Priority:
* 1. HA add-on mode → /data (mounted by HA Supervisor)
* 2. Standalone Docker with /data mount → /data (user-mounted volume)
* 3. Local dev → ./data relative to cwd (no /data exists)
*
* @param isAddOn - Whether running as HA add-on
* @param cwd - Current working directory (for local dev fallback)
* @returns Absolute path to data directory
*/
export function detectDataDir(isAddOn: boolean, cwd: string): string {
if (isAddOn) return '/data'
// NOTE: In standalone Docker, users mount -v ./trmnl-data:/data
// existsSync('/data') is true when a volume is mounted there
if (existsSync('/data')) return '/data'
return `${cwd}/data`
}
/**
* Parse boolean from environment variable string
* @param value - String value from env var
+2 -9
View File
@@ -8,20 +8,17 @@
*/
import fs from 'node:fs/promises'
import { existsSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type {
Schedule,
ScheduleInput,
ScheduleUpdate,
} from '../types/domain.js'
import { DATA_DIR } from '../const.js'
import { schedulerLogger } from './logger.js'
const log = schedulerLogger()
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// =============================================================================
// FILE LOCKING (prevents race conditions on concurrent writes)
// =============================================================================
@@ -62,11 +59,7 @@ async function withLock<T>(
}
}
// NOTE: existsSync is used at startup only (sync is fine for config detection)
const isAddOn = existsSync('/data/options.json')
const DEFAULT_SCHEDULES_FILE = isAddOn
? '/data/schedules.json'
: path.join(__dirname, '..', 'data', 'schedules.json')
const DEFAULT_SCHEDULES_FILE = path.join(DATA_DIR, 'schedules.json')
/**
* Check if file exists (async)
+2 -5
View File
@@ -19,18 +19,15 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadSchedules } from './lib/scheduleStore.js'
import { ScheduleExecutor, type ScreenshotFunction, type ExecutionResult } from './lib/scheduler/schedule-executor.js'
import { CronJobManager } from './lib/scheduler/cron-job-manager.js'
import { SCHEDULER_RELOAD_INTERVAL_MS, SCHEDULER_OUTPUT_DIR_NAME } from './const.js'
import { SCHEDULER_RELOAD_INTERVAL_MS, SCHEDULER_OUTPUT_DIR_NAME, DATA_DIR } from './const.js'
import type { Schedule } from './types/domain.js'
import { schedulerLogger } from './lib/logger.js'
const log = schedulerLogger()
const __dirname = path.dirname(fileURLToPath(import.meta.url))
/**
* High-level scheduler orchestrating cron jobs and screenshot execution.
*/
@@ -46,7 +43,7 @@ export class Scheduler {
* @param screenshotFn - Screenshot capture function (async)
*/
constructor(screenshotFn: ScreenshotFunction) {
this.#outputDir = path.join(__dirname, SCHEDULER_OUTPUT_DIR_NAME)
this.#outputDir = path.join(DATA_DIR, SCHEDULER_OUTPUT_DIR_NAME)
this.#cronManager = new CronJobManager()
this.#executor = new ScheduleExecutor(screenshotFn, this.#outputDir)
+15 -1
View File
@@ -2,5 +2,19 @@
set -e
echo "Starting TRMNL HA..."
mkdir -p logs output data
# Ensure data subdirectories exist for persistence.
# Supports two mount points:
# -v ./trmnl-data:/data (recommended, matches docs)
# -v ./trmnl-data:/app/data (also works, legacy workaround)
# In HA add-on mode, /data is managed by HA Supervisor.
if [ -d "/data" ]; then
mkdir -p /data/output
else
mkdir -p data/output
fi
# Local app directories (logs stay app-local, not persisted)
mkdir -p logs
exec "$@"
@@ -17,6 +17,7 @@ import {
isValidTimezone,
hasEnvConfig,
detectIsAddOn,
detectDataDir,
parseEnvBoolean,
isNetworkError,
parseOptionsFile,
@@ -204,6 +205,32 @@ describe('detectIsAddOn', () => {
})
})
// =============================================================================
// Data Directory Detection
// =============================================================================
describe('detectDataDir', () => {
it('returns /data for HA add-on mode', () => {
expect(detectDataDir(true, '/app')).toBe('/data')
})
it('returns cwd/data when /data does not exist', () => {
// Covers local dev AND the -v ./trmnl-data:/app/data workaround mount.
// In Docker with WORKDIR /app, cwd/data resolves to /app/data.
const result = detectDataDir(false, '/Users/dev/project')
expect(result).toBe('/Users/dev/project/data')
})
it('supports /app/data path in Docker (WORKDIR /app fallback)', () => {
// Simulates Docker with WORKDIR /app and -v ./trmnl-data:/app/data.
// Since /data doesn't exist on the host, falls through to cwd/data.
// NOTE: In Docker with -v ./x:/data, existsSync('/data') is true → '/data'.
// Both mount points are supported; this test covers the fallback branch.
const result = detectDataDir(false, '/app')
expect(['/data', '/app/data']).toContain(result)
})
})
// =============================================================================
// Boolean Environment Variable Parsing
// =============================================================================