Upgrading self-hosted Pangolin Enterprise Edition from 1.21 to 1.25.3 (and patching Node2Shell)

Pangolin is the tunneled reverse proxy in front of most of my self-hosted services. It runs as a small Docker Compose stack of Pangolin, Gerbil (WireGuard) and Traefik on a VPS. Mine was still on the Enterprise Edition image ee-1.21.0. When Node2Shell (CVE-2026-111497) was published for Node.js, it was time to catch up four minor versions. This post covers the path I took, the versions that belong together, and how I verified the result.

Why now: Node2Shell

  • What it is: an out-of-bounds write in the Brotli decoder that Node.js bundles. Node.js rates it high severity and says it can allow remote code execution when an application decodes attacker-supplied Brotli data.
  • Fixed versions: Node.js 22.23.4, 24.22.1 and 26.12.1, released on 21 October 2026. Older end-of-life lines get no fix.
  • Public PoC: a proof of concept appeared one day later.

As with PHP2Shell a few days earlier, the bug sits in the runtime. Updating Pangolin only helps if the new image ships a fixed Node.js, so that is the thing to verify at the end, not the Pangolin version number.

The starting point

name: pangolin
services:
  pangolin:
    image: docker.io/fosrl/pangolin:ee-1.21.0
    volumes:
      - ./config:/app/config
    # healthcheck on http://localhost:3001/api/v1/

  gerbil:
    image: docker.io/fosrl/gerbil:1.4.3
    # ports 51820/udp, 21820/udp, 80, 443

  traefik:
    image: docker.io/traefik:v3.7
    network_mode: service:gerbil
    volumes:
      - ./config/traefik:/etc/traefik:ro
      - ./config/letsencrypt:/letsencrypt

Enterprise Edition image tags

The examples in the GitHub repo and docs mostly use fosrl/pangolin:latest or a plain version number. Those are Community Edition images. If you run the Enterprise Edition (free for personal use and small organizations, but it needs a license key), you have to stay on the ee- tags. Otherwise the EE features are locked after the update.

  • SQLite: fosrl/pangolin:ee-<version>, for example ee-1.25.3 (floating: ee-latest, ee-1.25)
  • PostgreSQL: fosrl/pangolin:ee-postgresql-<version>

Both editions share the same database schema, so switching between them needs no data migration.

Upgrade path and matching versions

The Pangolin docs recommend upgrading incrementally between versions (1.0 → 1.1 → 1.2) instead of jumping. Migrations run automatically on startup and the database cannot be downgraded afterwards, so I went one minor version at a time, using the newest patch release of each:

  1. ee-1.22.2, together with Gerbil and Badger
  2. ee-1.23.0
  3. ee-1.24.0
  4. ee-1.25.3

The other components:

  • Gerbil 1.4.3 → 1.5.2. Pangolin 1.22 needs Gerbil 1.5.0 or newer for private AI gateway resources, and the migration does not update Gerbil for you. 1.5.2 is 1.5.0 plus bug fixes.
  • Traefik v3.7 stays. Pangolin 1.24 needs at least Traefik 3.6 for all load-balancing options.
  • Badger v1.5.0 → v1.7.0. Badger is the Traefik plugin in config/traefik/traefik_config.yml. Pangolin 1.22 needs v1.6.0 or newer. Do not use v1.8.0 for now: it fails Traefik’s plugin integrity check, which disables the plugin. Redirects in 1.25 work with v1.7.0; only permanent redirects need v1.8.0.

Step 0: check the tags and back up

Before starting, I made sure every intermediate EE tag actually exists, and also looked for newer patch releases:

for t in ee-1.22.2 ee-1.23.0 ee-1.24.0 ee-1.25.2 ee-1.25.3; do
  docker manifest inspect docker.io/fosrl/pangolin:$t >/dev/null 2>&1 \
    && echo "$t ok" || echo "$t MISSING"
done

All of them existed, including ee-1.25.3, which became the final target. Then the backup, with the stack stopped so the SQLite database is consistent:

cd /opt/stacks/pangolin
docker compose down
sudo tar czf ~/pangolin-backup-$(date +%F).tgz config/

Pangolin also copies the SQLite file automatically before each migration, but since downgrading is not possible after a schema change, I wanted a complete copy of config/ anyway.

Step 1: Pangolin 1.22.2, Gerbil 1.5.2, Badger v1.7.0

# Badger 1.5.0 -> 1.7.0
grep -A3 badger config/traefik/traefik_config.yml
sed -i 's|version: v1.5.0|version: v1.7.0|' config/traefik/traefik_config.yml

# Pangolin + Gerbil
sed -i 's|fosrl/pangolin:ee-1.21.0|fosrl/pangolin:ee-1.22.2|; s|fosrl/gerbil:1.4.3|fosrl/gerbil:1.5.2|' docker-compose.yml
grep image: docker-compose.yml

docker compose pull
docker compose up -d
docker compose logs -f pangolin

The migration log is short and easy to read:

Starting migrations from version 1.21.0
Migrations to run: 1.22.0
Running migration 1.22.0
Migrated database
Updated the version of Badger in your Traefik configuration to v1.7.0
Successfully completed migration 1.22.0
All migrations completed successfully

The migration bumps Badger itself if the Traefik config is in the standard location. Here it just rewrote the value I had already set. Setting it by hand is still worth it, because if Pangolin cannot update the file it fails silently.

Plugin problems do not show up in the Pangolin log, so I checked Traefik separately:

docker compose ps
docker compose logs traefik | grep -i -E 'badger|plugin|error'
# INF Loading plugins... plugins=["badger"]
# INF Plugins loaded. plugins=["badger"]

The same grep also surfaced a couple of Let’s Encrypt errors for two unrelated hostnames: one domain paused after repeated failed validations, and one renewal failing over IPv6 because of its AAAA record. They have nothing to do with the upgrade; the restart just made Traefik retry. They go on the to-do list.

Steps 2 to 4: one minor version at a time

From here on only the Pangolin tag changes. Each step took about a minute:

sed -i 's|pangolin:ee-1.22.2|pangolin:ee-1.23.0|' docker-compose.yml
docker compose pull pangolin && docker compose up -d && docker compose logs -f pangolin

sed -i 's|pangolin:ee-1.23.0|pangolin:ee-1.24.0|' docker-compose.yml
docker compose pull pangolin && docker compose up -d && docker compose logs -f pangolin

sed -i 's|pangolin:ee-1.24.0|pangolin:ee-1.25.3|' docker-compose.yml
docker compose pull pangolin && docker compose up -d && docker compose logs -f pangolin

After each step I waited for All migrations completed successfully and for the Newt site to reconnect (WebSocket connection fully established) before going on. Things I noticed along the way:

  • 1.23 printed Node.js ExperimentalWarning lines about Web Crypto. They are harmless and a hint that the image moved to a newer Node.js.
  • 1.24 starts Node with --disable-warning=ExperimentalWarning, so those lines disappear again.
  • 1.25.3 ran two migrations in a row, 1.25.0 and 1.25.3.
  • Repeated Site 1 endpoint changed messages are normal: the NAT on the site’s side hands the tunnel a new source port now and then.
  • The Email SMTP configuration is missing warning only means no mail server is configured.

Verifying the Node2Shell fix

You can check the Node.js version of an image before deploying it:

docker run --rm --entrypoint node docker.io/fosrl/pangolin:ee-1.25.3 --version

And the running container afterwards:

docker exec pangolin node --version
# v24.22.1

v24.22.1 is the fixed release of the Node.js 24 line, so the container is patched. If it shows anything older than 22.23.4, 24.22.1 or 26.12.1, the Pangolin version alone did not fix it and you have to wait for a newer image.

Result

  • Pangolin ee-1.25.3, Gerbil 1.5.2, Traefik v3.7, Badger v1.7.0
  • Node.js inside the container: v24.22.1
  • Total time: about 15 minutes, with a minute or two of downtime per step

Leftovers: update Newt sites and the Pangolin clients as well (1.25.1 fixed a relay issue for updated clients), keep the backup tarball for a few days, sort out the two certificate problems, and move Badger to v1.8.0 once the integrity check issue is fixed.

Sources

Leave a comment