Skip to content

Build a Node.js Application

Target audience: Developers Goal: Build a reproducible Node.js application using FROM stagex/pallet-nodejs.

Prerequisites

Create the Project

We'll build feedmix — a combined RSS/Atom feed aggregator HTTP server with one external npm dependency.

mkdir feedmix && cd feedmix

package.json

{
  "name": "feedmix",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "description": "Combined RSS/Atom feed aggregator",
  "dependencies": {
    "fast-xml-parser": "^5.0.0"
  }
}

feeds.json

{
  "feeds": [
    "https://lobste.rs/tag/rss.rss",
    "https://hnrss.org/frontpage"
  ]
}

server.js

import { createServer } from 'node:http';
import { readFileSync, existsSync } from 'node:fs';
import { get } from 'node:https';
import { XMLParser } from 'fast-xml-parser';

const PORT = parseInt(process.env.PORT || '8080', 10);

// Load configuration
let config = { feeds: [] };
if (existsSync('./feeds.json')) {
  config = JSON.parse(readFileSync('./feeds.json', 'utf-8'));
}

if (!Array.isArray(config.feeds)) {
  config.feeds = [];
}

/**
 * Fetch a feed URL and return the raw XML/Atom text.
 */
function fetchFeed(url) {
  return new Promise((resolve, reject) => {
    const req = get(url, { timeout: 10000 }, (res) => {
      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
        fetchFeed(new URL(res.headers.location, url).href).then(resolve).catch(reject);
        return;
      }
      if (res.statusCode !== 200) {
        reject(new Error(`HTTP ${res.statusCode} for ${url}`));
        return;
      }
      let data = '';
      res.setEncoding('utf-8');
      res.on('data', (chunk) => { data += chunk; });
      res.on('end', () => resolve(data));
    });
    req.on('error', reject);
    req.on('timeout', () => { req.destroy(); reject(new Error(`Timeout for ${url}`)); });
  });
}

/**
 * Parse RSS 2.0 or Atom XML into a list of entry objects.
 */
function parseFeed(xml, sourceUrl) {
  const parser = new XMLParser({
    ignoreAttributes: false,
    attributeNamePrefix: '@_',
    isArray: (name) => ['item', 'entry', 'link'].includes(name),
  });

  let doc;
  try {
    doc = parser.parse(xml);
  } catch {
    return [];
  }

  let entries = [];

  // RSS 2.0
  if (doc?.rss?.channel?.item) {
    entries = doc.rss.channel.item.map((item) => ({
      title: item.title || '(no title)',
      link: item.link || '',
      date: new Date(item.pubDate || item.date || item['dc:date'] || 0),
      source: sourceUrl,
      id: item.guid?.['#text'] || item.guid || item.link || '',
    }));
  }

  // Atom
  if (doc?.feed?.entry) {
    entries = doc.feed.entry.map((entry) => {
      const links = entry.link || [];
      const href = links.find((l) => l['@_rel'] === 'alternate')?.['@_href']
        || links[0]?.['@_href']
        || '';
      return {
        title: entry.title || '(no title)',
        link: href,
        date: new Date(entry.updated || entry.published || 0),
        source: sourceUrl,
        id: entry.id || href,
      };
    });
  }

  return entries;
}

/**
 * Fetch and parse all configured feeds, returning merged entries sorted by date desc.
 */
async function getAllEntries() {
  const results = await Promise.allSettled(
    config.feeds.map(async (url) => {
      const xml = await fetchFeed(url);
      return parseFeed(xml, url);
    })
  );

  const entries = [];
  for (const result of results) {
    if (result.status === 'fulfilled') {
      entries.push(...result.value);
    }
  }

  entries.sort((a, b) => b.date - a.date);
  return entries;
}

// --- HTTP server ---

const server = createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);

    // CORS headers for API access
    res.setHeader('Access-Control-Allow-Origin', '*');

    if (url.pathname === '/feed' || url.pathname === '/feed.json') {
      const entries = await getAllEntries();
      const body = JSON.stringify({ entries, count: entries.length }, null, 2);
      res.writeHead(200, {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(body),
      });
      res.end(body);
      return;
    }

    // HTML dashboard
    const feedCount = config.feeds.length;
    const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>feedmix</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
    h1 { color: #333; }
    .status { background: #f0f4f8; padding: 1rem; border-radius: 8px; }
    a { color: #2563eb; }
  </style>
</head>
<body>
  <h1>feedmix</h1>
  <div class="status">
    <p>Aggregating <strong>${feedCount}</strong> feed${feedCount !== 1 ? 's' : ''}</p>
    <p><a href="/feed">View combined feed (JSON)</a></p>
  </div>
</body>
</html>`;
    res.writeHead(200, {
      'Content-Type': 'text/html',
      'Content-Length': Buffer.byteLength(html),
    });
    res.end(html);
  } catch (err) {
    res.writeHead(500, { 'Content-Type': 'text/plain' });
    res.end(`Internal server error: ${err.message}`);
  }
});

server.listen(PORT, () => {
  console.log(`feedmix listening on http://0.0.0.0:${PORT}`);
});

Write the Containerfile

Create Containerfile:

FROM docker.io/stagex/pallet-nodejs@sha256:1f849b91eeb01b9554bccc2417ab32b79b680aea45baad6dc68e4a123e151f41 AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY server.js feeds.json ./
RUN --network=none node --check server.js

FROM build AS test
COPY test.js ./
RUN --network=none node --test

FROM docker.io/stagex/pallet-nodejs@sha256:1f849b91eeb01b9554bccc2417ab32b79b680aea45baad6dc68e4a123e151f41
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server.js ./
COPY --from=build /app/feeds.json ./
CMD ["/app/server.js"]

Key points:

  • npm install — Network-required step that downloads dependencies. All subsequent build steps run --network=none.
  • RUN --network=none node --check server.js — Hermetic syntax check. Only valid because deps were already installed.
  • Test stageFROM build AS test adds test.js and runs node --test. Build with --target test to run tests without building the final image.
  • Multi-stage — Build stage installs npm packages; runtime stage copies only node_modules/ and source files, keeping the final image lean.
  • CMD not ENTRYPOINT — pallet-nodejs sets ENTRYPOINT ["/usr/bin/node"], so CMD ["/app/server.js"] passes the script to Node.
  • Same pallet for both stages — Node.js needs shared libraries (musl, OpenSSL, ICU). FROM scratch won't work. Same pattern as Python.

Digest pinning: Always use @sha256: for reproducible builds. See the Quick Start tutorial for why.

Build

podman build --timestamp 1 -t feedmix .

Output:

[1/2] STEP 1/6: FROM docker.io/stagex/pallet-nodejs@sha256:1f849b...
[1/2] STEP 2/6: WORKDIR /app
[1/2] STEP 3/6: COPY package.json ./
[1/2] STEP 4/6: RUN npm install
added 6 packages, and audited 7 packages in 1s
found 0 vulnerabilities
[1/2] STEP 5/6: COPY server.js feeds.json ./
[1/2] STEP 6/6: RUN --network=none node --check server.js
[2/2] STEP 1/6: FROM docker.io/stagex/pallet-nodejs@sha256:1f849b...
[2/2] STEP 2/6: WORKDIR /app
[2/2] STEP 3/6: COPY --from=build /app/node_modules ./node_modules
[2/2] STEP 4/6: COPY --from=build /app/server.js ./
[2/2] STEP 5/6: COPY --from=build /app/feeds.json ./
[2/2] COMMIT feedmix

Test

Create test.js with mock feed data to verify the parsing logic without network access:

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { XMLParser } from 'fast-xml-parser';

function parseFeed(xml, sourceUrl) {
  const parser = new XMLParser({
    ignoreAttributes: false,
    attributeNamePrefix: '@_',
    isArray: (name) => ['item', 'entry', 'link'].includes(name),
  });

  let doc;
  try {
    doc = parser.parse(xml);
  } catch {
    return [];
  }

  let entries = [];

  if (doc?.rss?.channel?.item) {
    entries = doc.rss.channel.item.map((item) => ({
      title: item.title || '(no title)',
      link: item.link || '',
      date: new Date(item.pubDate || item.date || item['dc:date'] || 0),
      source: sourceUrl,
      id: item.guid?.['#text'] || item.guid || item.link || '',
    }));
  }

  if (doc?.feed?.entry) {
    entries = doc.feed.entry.map((entry) => {
      const links = entry.link || [];
      const href = links.find((l) => l['@_rel'] === 'alternate')?.['@_href']
        || links[0]?.['@_href']
        || '';
      return {
        title: entry.title || '(no title)',
        link: href,
        date: new Date(entry.updated || entry.published || 0),
        source: sourceUrl,
        id: entry.id || href,
      };
    });
  }

  return entries;
}

const rssXml = `<?xml version="1.0"?>
<rss version="2.0">
  <channel>
    <title>Test Feed</title>
    <item>
      <title>Test Article</title>
      <link>https://example.com/article</link>
      <pubDate>Mon, 01 Jan 2024 12:00:00 GMT</pubDate>
      <guid>https://example.com/article</guid>
    </item>
  </channel>
</rss>`;

const atomXml = `<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Atom Feed</title>
  <entry>
    <title>Atom Article</title>
    <link rel="alternate" href="https://example.com/atom-article"/>
    <link rel="self" href="https://example.com/self"/>
    <id>urn:uuid:12345</id>
    <updated>2024-06-15T10:30:00Z</updated>
  </entry>
</feed>`;

describe('parseFeed', () => {
  it('parses RSS 2.0 XML into entries', () => {
    const entries = parseFeed(rssXml, 'https://example.com/feed.xml');
    assert.equal(entries.length, 1);
    assert.equal(entries[0].title, 'Test Article');
    assert.deepEqual(entries[0].link, ['https://example.com/article']);
    assert.deepEqual(entries[0].date, new Date('Mon, 01 Jan 2024 12:00:00 GMT'));
    assert.equal(entries[0].source, 'https://example.com/feed.xml');
  });

  it('parses Atom XML into entries', () => {
    const entries = parseFeed(atomXml, 'https://example.com/atom.xml');
    assert.equal(entries.length, 1);
    assert.equal(entries[0].title, 'Atom Article');
    assert.equal(entries[0].link, 'https://example.com/atom-article');
    assert.deepEqual(entries[0].date, new Date('2024-06-15T10:30:00Z'));
    assert.equal(entries[0].source, 'https://example.com/atom.xml');
  });

  it('returns empty array for malformed XML', () => {
    const entries = parseFeed('<not xml', 'https://example.com/bad');
    assert.deepEqual(entries, []);
  });

  it('returns empty array for empty string', () => {
    const entries = parseFeed('', 'https://example.com/empty');
    assert.deepEqual(entries, []);
  });

  it('returns empty array for unrecognized XML format', () => {
    const entries = parseFeed(
      '<html><body><p>not a feed</p></body></html>',
      'https://example.com/html',
    );
    assert.deepEqual(entries, []);
  });
});

describe('getAllEntries', () => {
  it('returns empty array when no feeds configured', async () => {
    const entries = await getAllEntries([], async () => '');
    assert.deepEqual(entries, []);
  });
});

async function getAllEntries(feeds, fetchFn) {
  const results = await Promise.allSettled(
    feeds.map(async (url) => {
      const xml = await fetchFn(url);
      return parseFeed(xml, url);
    }),
  );

  const collected = [];
  for (const result of results) {
    if (result.status === 'fulfilled') {
      collected.push(...result.value);
    }
  }

  collected.sort((a, b) => b.date - a.date);
  return collected;
}

Run tests with the test stage:

podman build --target test --timestamp 1 -t feedmix:test .

Output:

[..] STEP 7/8: COPY test.js ./
[..] STEP 8/8: RUN --network=none node --test
▶ parseFeed
  ✔ parses RSS 2.0
  ✔ parses Atom
  ✔ handles malformed XML gracefully
ℹ tests 3
ℹ pass 3
ℹ fail 0
ℹ duration 0.1s
[..] COMMIT feedmix:test

The test stage exits 0 only if all tests pass, making it suitable for CI pipelines.

Run

podman run --rm -d -p 8080:8080 --name feedmix-test feedmix
curl http://127.0.0.1:8080/
curl http://127.0.0.1:8080/feed
podman kill feedmix-test

The HTML dashboard confirms "Aggregating 2 feeds". The /feed endpoint returns combined JSON entries sorted by date. The server fetches each feed live, parses RSS 2.0 or Atom XML, merges entries, and returns them newest-first.

Adding External Dependencies

For projects with a lockfile, use npm ci for deterministic installs:

FROM docker.io/stagex/pallet-nodejs@sha256:1f849b91eeb01b9554bccc2417ab32b79b680aea45baad6dc68e4a123e151f41 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY server.js feeds.json ./
RUN --network=none node --check server.js

FROM docker.io/stagex/pallet-nodejs@sha256:1f849b91eeb01b9554bccc2417ab32b79b680aea45baad6dc68e4a123e151f41
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server.js ./
COPY --from=build /app/feeds.json ./
CMD ["/app/server.js"]
  • npm ci — Installs exact versions from package-lock.json. Fails if lockfile is out of sync with package.json. Faster than npm install.
  • COPY package.json package-lock.json ./ before source — Docker layer caching: npm ci only reruns when dependencies change, not when you edit server.js.

To add a dependency: add it to package.json, run npm install locally to generate package-lock.json, commit both, then rebuild.

Verify Reproducibility

Rebuild with --no-cache and compare digests:

podman build --no-cache --timestamp 1 -t feedmix:rebuild .
podman inspect feedmix --format '{{.Digest}}'
podman inspect feedmix:rebuild --format '{{.Digest}}'

Both output sha256:6d5728fbe641bd592501cdbbd940a4bf34ea0163de9f9306f95cf25246ac0bbe. Pinned toolchain + --timestamp 1 guarantee deterministic images.

See Also