Dockerize a Node.js Script and Run It on a Synology NAS
A reusable pattern for unattended background scripts.
Package a Playwright based Node.js script into a Docker image, pin it to a known-good version, and run it as a permanent background service on a Synology NAS with DSM 7.2 and Container Manager.
Introduction
This guide walks through containerizing a small Node.js script that runs a Playwright end-to-end test suite on a repeating schedule and emails a summary when a run fails. The worked example throughout is a small test suite for a personal project, but the pattern (running tests on an interval inside a container, keeping the last result, emailing on failure) applies to any script built the same way.
The end result runs as a permanently-alive container, not a one-shot job triggered by a scheduler: the script keeps its own interval loop, and Docker’s job is only to keep that process running, restart it if it crashes, and isolate its dependencies.
Why containerize a background script
A script like this already runs on a development machine with node runner.js. Moving it into a container solves three separate problems at once.
Reproducible dependencies
Playwright downloads a specific build of Chromium tied to the installed playwright npm package version. A container image pins that pairing so the browser that runs in production is the one that was tested, not whatever happens to be on the host.
Unattended restarts
A NAS reboots for updates, and a long-running Node process can crash. A container with a restart policy comes back on its own, without a login session or a cron entry to remember.
Isolation from the rest of the NAS
The script only needs a folder for its results file and network access. A container keeps it out of the NAS’s own Node or Python installation, and out of any other package on the box.
Prerequisites: check your Playwright version
The official Playwright Docker images are versioned to match the npm package exactly. The Playwright documentation is explicit about this: “If the Playwright version in your Docker image does not match the version in your project/tests, Playwright will be unable to locate browser executables.” The ^1.48.0 style range that npm install playwright writes into package.json is a floor, not the installed version, so check the version that is actually on disk before writing the Dockerfile.
$ cat package.json | grep playwright
$ npx playwright --versionCommit a package-lock.json if you do not already have one (npm install creates it). It pins the exact version for anyone, or anything, that rebuilds the image later.
Project layout
Six files, all at the root of the project:
runner.js | The script (see “Introduction”: run the test suite, save the result, email on failure) |
package.json / package-lock.json | Dependencies, pinned |
Dockerfile | Builds the image |
.dockerignore | Keeps secrets and local state out of the build context |
.env (not committed) | Real secrets and configuration, local only |
The example script below is a minimal, generic version of the real pattern: a loop, a JSON results file, and an email alert. Swap runTestSuite() for however your own project actually runs its tests, if not through the Playwright test runner directly.
require('dotenv').config();
const { execFile } = require('child_process');
const nodemailer = require('nodemailer');
const fs = require('fs');
const path = require('path');
const RESULTS_FILE = path.join(__dirname, 'results', 'last-run.json');
const INTERVALS_MIN = (process.env.INTERVALS_MIN || '360,420,480')
.split(',').map(Number).filter(n => n > 0);
function saveResult(result) {
fs.mkdirSync(path.dirname(RESULTS_FILE), { recursive: true });
fs.writeFileSync(RESULTS_FILE, JSON.stringify(result, null, 2));
}
async function sendSummary(subject, text) {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT || 587),
secure: Number(process.env.SMTP_PORT) === 465,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
await transporter.sendMail({
from: process.env.SMTP_USER,
to: process.env.ALERT_EMAIL,
subject,
text,
});
}
function runTestSuite() {
return new Promise((resolve) => {
execFile('npx', ['playwright', 'test', '--reporter=json'], { cwd: __dirname }, (error, stdout) => {
let stats = null;
try { stats = JSON.parse(stdout).stats; } catch { /* stdout wasn't JSON; stats stays null */ }
resolve({ passed: !error, stats });
});
});
}
async function runOnce() {
const { passed, stats } = await runTestSuite();
const result = { timestamp: new Date().toISOString(), passed, stats };
saveResult(result);
if (!passed) {
await sendSummary('Test run failed', 'One or more tests failed. See results/last-run.json for details.');
}
}
function randomIntervalMs() {
const m = INTERVALS_MIN[Math.floor(Math.random() * INTERVALS_MIN.length)];
return m * 60 * 1000;
}
async function main() {
console.log('Test runner started, checking every ' + INTERVALS_MIN.join('/') + ' min (randomized)');
while (true) {
try { await runOnce(); } catch (e) { console.error(e); }
await new Promise(r => setTimeout(r, randomIntervalMs()));
}
}
main();{
"name": "e2e-runner",
"version": "1.0.0",
"main": "runner.js",
"scripts": {
"start": "node runner.js"
},
"dependencies": {
"playwright": "1.63.0",
"nodemailer": "^6.9.13",
"dotenv": "^16.4.5"
},
"engines": {
"node": ">=18"
}
}The Dockerfile
The official Playwright image already contains Node, Chromium (and the other engines, if your tests use them) and their OS-level dependencies, so there is no separate base-image and browser-install step to maintain.
# Pin this to the Playwright version actually installed in your project
# (see "Prerequisites"). Do not use the semver range from package.json.
FROM mcr.microsoft.com/playwright:vX.Y.Z-noble
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
# Run as the image's built-in non-root user instead of root
USER pwuser
CMD ["node", "runner.js"]Replace vX.Y.Z-noble with the exact version from the “Prerequisites” step, for example v1.63.0-noble. The -noble suffix targets an Ubuntu 24.04 base image; Playwright also publishes -jammy (22.04). The two must be the same family the reader’s own Docker host expects, but since the image is self-contained this rarely matters; keep -noble unless a specific reason says otherwise.
Why npm ci and not npm install: npm ci installs exactly what package-lock.json records and fails if the lockfile and package.json disagree, instead of silently updating anything. That is what makes the image reproducible.
Why USER pwuser: the official image’s default user is root, which also disables Chromium’s own sandbox. pwuser is the image’s built-in non-root account; running as it keeps the sandbox enabled. For a script that only visits one known, trusted page (as here) root without the sandbox is a documented, acceptable trade-off for end-to-end testing; for anything closer to scraping or visiting untrusted pages, prefer pwuser.
Secrets and configuration
Nothing that changes between environments, and nothing secret, belongs in the image. Read it from the environment instead.
INTERVALS_MIN=360,420,480
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-address@gmail.com
SMTP_PASS=your-16-character-app-password
ALERT_EMAIL=you@example.orgnode_modules
.env
.git
results/
debug/
npm-debug.logGmail: if the alert email goes through a Gmail account, a plain account password will not work for SMTP. Google’s own documentation states that “app passwords can only be used with accounts that have 2-Step Verification turned on.” Turn on 2-Step Verification first, then generate a 16-character app password and use that as SMTP_PASS, never the account password itself.
Build and test the container locally
Build and run it on the development machine first, with the real .env filled in, before touching the NAS.
$ npm install
$ npx playwright --version
$ docker build -t e2e-runner .
$ mkdir -p results debug
$ docker run -d --name e2e-runner --init --ipc=host
--env-file .env
-v "$(pwd)/results:/app/results"
-v "$(pwd)/debug:/app/debug"
--restart unless-stopped
e2e-runner
$ docker logs -f e2e-runnerWatch the logs for one full cycle. If a test is failing, confirm the email actually arrives before moving to the NAS: debugging a container on a NAS with no shell access to the host is slower than debugging it locally.
Docker Compose for a permanent Synology deployment
A NAS is not the development machine, so the compose file’s paths change accordingly.
Absolute paths only: generic Docker Compose examples commonly use relative paths such as ./results:/app/results. On Synology’s Container Manager these do not resolve the way they do on a normal Docker host; volumes need an absolute host path such as /volume1/docker/e2e-runner/results:/app/results. This is a documented quirk of Container Manager’s Project feature, not a Docker Compose requirement in general.
services:
e2e-runner:
build: .
container_name: e2e-runner
restart: unless-stopped
init: true
ipc: host
environment:
INTERVALS_MIN: "360,420,480"
SMTP_HOST: "smtp.gmail.com"
SMTP_PORT: "587"
SMTP_USER: "your-address@gmail.com"
SMTP_PASS: "your-16-character-app-password"
ALERT_EMAIL: "you@example.org"
volumes:
- /volume1/docker/e2e-runner/results:/app/results
- /volume1/docker/e2e-runner/debug:/app/debug
mem_limit: 1grestart: unless-stopped brings the container back after a crash or a NAS reboot. init: true and ipc: host are Playwright’s own recommendations for running Chromium in a container: init avoids treating the app process as PID 1, and ipc: host gives Chromium enough shared memory without a separate /dev/shm size override. mem_limit caps the container so one runaway process cannot starve everything else DSM is running (see “Deploy on Synology” for why this matters on this model).
Do not commit this file with real values filled in. Keep a template with placeholders in version control, and fill in the real secrets only in the copy that lives on the NAS.
Deploy on Synology DSM with Container Manager
Steps below are for DSM 7.2, where Container Manager’s Project tab (the docker-compose feature) is available.
1. Pre-create every folder the compose file mounts
In File Station, create /volume1/docker/e2e-runner/, and inside it results/ and debug/. Container Manager does not create missing subfolders referenced in volumes:; the project fails to start if they are missing.
2. Copy the project files onto the NAS
Copy Dockerfile, package.json, package-lock.json and runner.js into /volume1/docker/e2e-runner/ (for example over the network share, or with File Station’s upload).
3. Create the project
Container Manager > Project > Create. Give it a name, set the path to /volume1/docker/e2e-runner/, and choose Create docker-compose.yml to paste in the file from the previous section, with the real values filled in.
4. Build and start
Container Manager builds the image from the Dockerfile and starts the service. Follow progress in the project’s Log tab.
Resource budget on older models: a DS916+ ships with 2 GB of RAM (expandable to 8 GB), shared with DSM itself and every other package running on the box. A Node process driving headless Chromium is heavier than most Synology packages. Check Resource Monitor after the first few cycles, and lower mem_limit (or add RAM) if the container is close to its cap.
Verify the deployment
Four checks, in order of how much they actually prove:
- The project’s status in Container Manager reads Running, and the Log tab shows the startup line from
main(). - Restart the container from Container Manager (or run the commands below over SSH) and confirm it comes back on its own.
- Reboot the NAS from Control Panel > Update & Restore and confirm the container is Running again afterward without any manual step.
- Confirm
results/last-run.jsonon the NAS still holds its previous content after a restart: that proves the results live in the mounted volume and not only inside the container’s own filesystem.
$ docker inspect -f "{{.State.Status}}" e2e-runner
$ docker inspect -f "{{.HostConfig.RestartPolicy.Name}}" e2e-runner
$ cat results/last-run.jsonCommon pitfalls
Image and package version mismatch
The most common failure: the Dockerfile’s tag and the installed playwright package drift apart after a later npm update. Re-check the version (see “Prerequisites”) and rebuild whenever package-lock.json changes.
Relative paths in the compose file
Works on a normal Docker host, fails silently or refuses to start on Container Manager. Use absolute /volume1/... paths there.
Folders not pre-created
Container Manager will not create a missing subfolder referenced by volumes:; create every one of them in File Station first.
Secrets or results committed to Git
.env and the results/ folder belong in .dockerignore and, separately, in the project’s .gitignore. Neither should ever reach a public repository.
Gmail without an app password
An ordinary account password will not authenticate over SMTP once 2-Step Verification is on, which it should be. Use an app password, generated after 2-Step Verification is enabled.
Debug captures filling the disk
If the script writes a screenshot or an HTML dump on every run (useful for debugging a selector that stopped matching), mount that folder as a volume too, and prune it periodically; left unmounted, it grows inside the container’s own writable layer instead.
Root disables the Chromium sandbox
The official Playwright image runs as root by default, which turns off Chromium’s sandbox. Fine for a small number of trusted, fixed pages; switch to pwuser (see “The Dockerfile”) for anything closer to open-ended scraping.
A flaky test spamming email on every run
A single unreliable test turns every run into a failure email. Fix or quarantine flaky tests rather than shortening the interval to catch the failure sooner; a suite that fails intermittently is a signal worth acting on directly, not smoothing over.
What you have now
A background script that:
- Runs inside a container image pinned to a known Playwright and browser version
- Comes back on its own after a crash or a NAS reboot, through
restart: unless-stopped - Keeps its results and any debug output outside the container’s own filesystem, on mounted volumes
- Reads its secrets from the environment, never from the image
- New to Docker itself? See the “Docker essentials” guide for the underlying image, layer and Compose concepts this guide builds on
- Add a simple health signal (a log line, or a file touched on every successful cycle) if something outside the NAS should be able to tell the container is still alive
- Revisit the image tag whenever
package-lock.jsonchanges, not only when something breaks
Official documentation
- Playwright: Docker (image tags, version matching, –init and –ipc=host)
- Google: Sign in with app passwords
- Synology: DS916+ data sheet (CPU and RAM)
Synology’s own Knowledge Center pages for Container Manager’s Project feature did not return readable content at the time of writing (JavaScript-rendered). The absolute-path and folder-pre-creation behavior described under “Deploy on Synology” is confirmed against a detailed third-party walkthrough instead: WunderTech, “Synology Container Manager: How to Use Docker & Docker Compose” (published August 2026).
Keep going
Dockerize a Node.js Script and Run It on a Synology NAS, by Seif. Last updated: September 2026. Image tags, Compose keys and DSM/Container Manager behavior can change between versions, so check the official documentation for yours.
benmabrouk.fr: free DevOps and SRE learning resources, written from real-world experience.