diff --git a/.github/workflows/ci_netbsd.yml b/.github/workflows/ci_netbsd.yml
new file mode 100644
index 000000000..e4ab88e1a
--- /dev/null
+++ b/.github/workflows/ci_netbsd.yml
@@ -0,0 +1,69 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) Contributors to the OpenEXR Project.
+#
+# Build and test on NetBSD using a community VM action.
+# GitHub does not provide native NetBSD runners; this uses vmactions/netbsd-vm.
+# Validates the pthread fallback in internal_thread.h (issue #2299).
+
+name: CI (NetBSD)
+
+on:
+  push:
+    paths:
+      - '**'
+      - '!**.md'
+      - '!website/**'
+      - '!bazel/**'
+      - '!src/wrappers/**'
+      - '!.github/workflows/**'
+      - '.github/workflows/ci_netbsd.yml'
+  pull_request:
+    paths:
+      - '**'
+      - '!**.md'
+      - '!website/**'
+      - '!bazel/**'
+      - '!src/wrappers/**'
+      - '!.github/workflows/**'
+      - '.github/workflows/ci_netbsd.yml'
+
+permissions:
+  contents: read
+
+jobs:
+  netbsd:
+    name: NetBSD
+    runs-on: ubuntu-latest
+    steps:
+      - name: Build and test on NetBSD
+        uses: vmactions/netbsd-vm@c8a0d7ddb84619a7a8cc4e652efa7950b3fd9f92 # v1.5.2
+        with:
+          release: '10.2'
+          envs: GITHUB_REPOSITORY GITHUB_REF GITHUB_SHA
+          usesh: true
+          prepare: |
+            /usr/sbin/pkg_add pkgin
+            pkgin -y update
+            pkgin -y install cmake ninja-build git
+          run: |
+            set -e
+            git clone --depth 1 "https://github.com/${GITHUB_REPOSITORY}.git" src
+            cd src
+            # shallow clone, only the commit that triggered the run, i.e. GITHUB_SHA
+            git fetch --depth 1 origin "${GITHUB_SHA}"
+            git checkout "${GITHUB_SHA}"
+            cmake -B _build -S . -G Ninja \
+              -DCMAKE_BUILD_TYPE=Release \
+              -DOPENEXR_FORCE_INTERNAL_IMATH=ON \
+              -DOPENEXR_IMATH_TAG='5f27ba266d3ea1565e912570c30b5eafc89959f1' \
+              -DOPENEXR_FORCE_INTERNAL_DEFLATE=ON \
+              -DOPENEXR_FORCE_INTERNAL_ZSTD=ON \
+              -DOPENEXR_FORCE_INTERNAL_OPENJPH=ON \
+              -DBUILD_TESTING=ON
+            cmake --build _build
+            # NetBSD's sem_init() consumes a file descriptor per semaphore,
+            # and some tests (e.g. testMultiPartThreading) create many
+            # semaphores concurrently, exceeding the VM's default per-process
+            # open-file limit. Raise it before running the test suite.
+            ulimit -n 4096
+            ctest --test-dir _build --output-on-failure
diff --git a/src/lib/OpenEXRCore/compression.c b/src/lib/OpenEXRCore/compression.c
index ed8ee4703..fcec87a77 100644
--- a/src/lib/OpenEXRCore/compression.c
+++ b/src/lib/OpenEXRCore/compression.c
@@ -18,6 +18,28 @@
 #include "OpenEXRConfigInternal.h"
 
 #if OPENEXR_USE_INTERNAL_DEFLATE
+/*
+ * On NetBSD, system headers already pulled in above transitively via
+ * <stdint.h> (e.g. <sys/bswap.h>) declare real, non-static bswap16/32/64
+ * functions. The vendored libdeflate sources declare their own static
+ * inline functions of the same names, which conflicts ("static declaration
+ * follows non-static declaration"). Rename libdeflate's symbols for the
+ * scope of these includes so they can't collide with the system's.
+ * (NetBSD's <sys/bswap.h> also defines bswap16/32/64 as function-like
+ * macros, so undefine those first to avoid a macro-redefinition warning.)
+ */
+#    ifdef bswap16
+#        undef bswap16
+#    endif
+#    ifdef bswap32
+#        undef bswap32
+#    endif
+#    ifdef bswap64
+#        undef bswap64
+#    endif
+#    define bswap16 openexr_deflate_bswap16
+#    define bswap32 openexr_deflate_bswap32
+#    define bswap64 openexr_deflate_bswap64
 #    include "../../../external/deflate/lib/lib_common.h"
 #    include "../../../external/deflate/lib/arm/cpu_features.c"
 #    include "../../../external/deflate/lib/x86/cpu_features.c"
@@ -29,6 +51,9 @@
 #    include "../../../external/deflate/lib/adler32.c"
 #    include "../../../external/deflate/lib/zlib_compress.c"
 #    include "../../../external/deflate/lib/zlib_decompress.c"
+#    undef bswap16
+#    undef bswap32
+#    undef bswap64
 #else
 #    include <libdeflate.h>
 #endif
diff --git a/src/lib/OpenEXRCore/internal_coding.h b/src/lib/OpenEXRCore/internal_coding.h
index 9f1f2baec..6c6e32598 100644
--- a/src/lib/OpenEXRCore/internal_coding.h
+++ b/src/lib/OpenEXRCore/internal_coding.h
@@ -30,7 +30,13 @@
 #    include <x86intrin.h>
 #endif
 
+#ifdef __cplusplus
+#include <cmath>
+using std::isnan;
+using std::isinf;
+#else
 #include <math.h>
+#endif
 
 #ifdef __cplusplus
 extern "C" {
diff --git a/src/lib/OpenEXRCore/internal_ht_quality.h b/src/lib/OpenEXRCore/internal_ht_quality.h
index 968ae5687..5c1b0ba9b 100644
--- a/src/lib/OpenEXRCore/internal_ht_quality.h
+++ b/src/lib/OpenEXRCore/internal_ht_quality.h
@@ -6,7 +6,12 @@
 #ifndef OPENEXR_PRIVATE_HT_QUALITY_H
 #define OPENEXR_PRIVATE_HT_QUALITY_H
 
+#ifdef __cplusplus
+#include <cmath>
+using std::isfinite;
+#else
 #include <math.h>
+#endif
 
 /** Lower bound of the valid range for the LJ2K lossy quality level. */
 #define MIN_LOSSY_HTJ2K_QUALITY 1.f
diff --git a/src/test/OpenEXRCoreTest/read.cpp b/src/test/OpenEXRCoreTest/read.cpp
index f960c02f2..43c82f772 100644
--- a/src/test/OpenEXRCoreTest/read.cpp
+++ b/src/test/OpenEXRCoreTest/read.cpp
@@ -85,6 +85,12 @@ testReadBadArgs (const std::string& tempdir)
 #ifdef _WIN32
     EXRCORE_TEST_RVAL_FAIL (
         EXR_ERR_FILE_ACCESS, exr_start_read (&f, fn.c_str (), &cinit));
+#elif defined(__NetBSD__)
+    // NetBSD allows reading raw directory entries via read(), so the
+    // read succeeds but returns data that isn't a valid OpenEXR header,
+    // rather than failing with an I/O error as on other unix platforms.
+    EXRCORE_TEST_RVAL_FAIL (
+        EXR_ERR_FILE_BAD_HEADER, exr_start_read (&f, fn.c_str (), &cinit));
 #else
     EXRCORE_TEST_RVAL_FAIL (
         EXR_ERR_READ_IO, exr_start_read (&f, fn.c_str (), &cinit));
