* Fix TLS handshake failure for any host without a cached target Reported: fallback SSL doesn't work in the Docker build. Reproduced — it's worse than the fallback specifically: TLS was broken for nearly every connection, including ones with no SNI at all: $ curl -vk https://127.0.0.1/ * TLSv1.3 (IN), TLS alert, internal error (592) * OpenSSL/3.0.13: error:0A000438:SSL routines::tlsv1 alert internal error Root cause: targetinfo.lua's M.get() is shared by two call sites in two incompatible nginx phases — - proxy.conf's access_by_lua_block (a normal HTTP request phase, where ngx.exit() is valid) - nginx.conf's request_domain callback, which runs during the TLS handshake itself (ssl_certificate_by_lua*), where ngx.exit() is NOT a supported API M.get() called ngx.exit() on every lookup failure (no domain/SNI, a Redis error, or an unregistered host). When invoked from the SSL phase, that aborted the handshake with a bare "internal error" alert and produced no log output anywhere — silent and total, not limited to the unregistered-domain case, since even a connection with no SNI hits the same code path immediately. Fix: M.get() no longer calls ngx.exit() itself — it returns (nil, httpStatus) on failure. proxy.conf now checks the return value and calls ngx.exit() itself (the phase where that's actually supported). nginx.conf's request_domain guards the now-possibly-nil result before indexing it, and leaves ngx.ctx.toAllow unset on failure so allow_domain() correctly denies issuance and auto-ssl falls through to the static fallback cert in autossl.conf. Verified end to end against a running Docker build (deployed the changed files into a live container and reloaded, rather than relying on a full rebuild each iteration): - No SNI at all: TLS now completes; HTTP layer correctly returns 406 (previously: broken handshake, no response at all) - Unregistered SNI: same — TLS completes, 406, and openssl s_client confirms the cert served is genuinely the fallback (CN=sni-support-required-for-valid-ssl) - A real registered Host: TLS completes and proxies through to the backend correctly (confirms the success path is unaffected) - npm test: 192/192 pass * Fix footer not sticking to the bottom on short pages body had no sticky-footer layout at all (sso-manager-node already had this; proxy never did), so on any page with little content (e.g. /login) the footer sat right after the content instead of at the bottom of the viewport, leaving a large gap below it. Added the same flex-based pattern already used in sso-manager-node: body is a column flex container, #spa-shell grows to fill the remaining space, pushing the footer (the next sibling) to the bottom. Verified visually (screenshot) and via computed layout (footer.getBoundingClientRect().bottom === window.innerHeight) before and after. * Fix commit hash not showing in Docker builds build_info.js computed buildHash via `git rev-parse --short HEAD` at runtime, but the final image intentionally has no git binary and no .git directory (kept lean, per .dockerignore) — so this always failed silently and the footer's version line showed "unknown" for every Docker deployment. Working correctly only for bare-metal/dev, where git + .git are actually present. Added a throwaway gitinfo build stage that reuses the main base image (no extra pull) with git installed just for this stage, reads .git from the build context (now no longer excluded — see .dockerignore), and bakes the resolved short hash into a small file that IS copied into the final image. build_info.js reads that file first, falling back to the old git-rev-parse behavior (still needed for bare-metal). Verified against a real build: `docker exec proxy cat /app/.build_commit` matches `git rev-parse --short HEAD` on the host, and the footer now shows the real hash instead of "unknown". * Allow the local anti-lockout admin's initial password to be configured The local "proxyadmin2" bootstrap account was always created with username == password == "proxyadmin2" — a hardcoded, publicly-known default with no way to set it to something else before first boot. Fine for a quick local test, not for anything exposed publicly, and orchestrators like theta-env's setup.sh (which already generates a random password for the SSO admin) had no way to do the same here. Added conf.auth.localAdminPass (proxy-secrets.js / app_auth__localAdminPass): if set, it's used as the initial password instead of the hardcoded default. Only read on first creation — once the account exists this is never consulted again, so it's safe to leave set. Falls back to the previous behavior (password == username) when unset, so this is fully backward compatible. Verified: with app_auth__localAdminPass set, login with the new password succeeds and the old default ("proxyadmin2") is correctly rejected. Confirmed in a real Docker build too (secrets.js auth.localAdminPass), and npm test 192/192 pass. * Support GIT_COMMIT build-arg override for submodule builds The gitinfo stage from the previous commit works for a standalone clone (.git is a real directory) but not when this repo is built as a git submodule (e.g. from theta-env): a submodule's .git is a pointer FILE, not a directory — the real object database lives in the superproject's .git/modules/, outside this repo's own directory and therefore outside Docker's build context entirely. `git rev-parse` can never resolve it from in here no matter what, so builds via theta-env still baked in "unknown" despite the earlier fix. Add an optional GIT_COMMIT build-arg that, when set, wins over the in-context git resolution. theta-env's setup.sh now computes it on the host (where the submodule DOES resolve correctly) and passes it via docker-compose.yml's build.args. Verified via theta-env's actual setup.sh end to end: rebuilding with this change, `docker exec proxy cat /app/.build_commit` now matches `git -C proxy rev-parse --short HEAD` on the host (previously: "unknown", confirmed via the "[Warning] One or more build-args [GIT_COMMIT] were not consumed" message before this fix synced into the docker-compose.yml side).
Proxy
A reverse proxy and HTTPS termination service built on OpenResty/nginx, with a management API and web GUI. It puts any of your apps behind single sign-on (OIDC) and can also look users up directly in LDAP — so the same people who log in to your SSO are the people allowed to reach your proxied apps.
It handles the parts that are tedious to do by hand: automatic HTTPS certificates from Let's Encrypt (including wildcards via DNS-01), routing by hostname with wildcard matching, and per-host access control tied to your identity provider. You manage hosts, DNS providers, and permissions from a web UI or a REST API; the proxy serves them over TLS with auto-renewing certs and no downtime on changes.
Running it together with the theta42 SSO Manager? theta-env composes the two with one
setup.sh— it generates the OIDC + LDAP wiring from a singlesetup.envso the proxy and the SSO find each other without manual config.
Documentation: https://theta42.github.io/proxy/
Why this over the alternatives
Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with auto-HTTPS. This one is built around identity: it is both an OIDC client of an SSO provider (for browser login) and a direct LDAP client (for user lookups and per-host access control), so access decisions come from your real user directory, not a static allow-list or a separate auth proxy bolted on top. The trade-off is that it expects an OIDC/LDAP identity source to point at — it is not a standalone auth server. Pair it with the theta42 SSO Manager (bundled OpenLDAP + OIDC) for a self-hosted SSO + proxy stack, or point it at any OIDC provider + LDAP directory you already run.
Features
- Automated HTTPS/SSL certificate management via Let's Encrypt
- Support for HTTP-01 (auto-ssl) and DNS-01 (wildcard) ACME challenges
- Multiple DNS provider integrations (Cloudflare, DigitalOcean, PorkBun, DuckDNS — DuckDNS is free)
- Wildcard SSL certificate support with automatic renewal
- Dynamic host routing with wildcard domain matching (*, **)
- Web-based management interface
- RESTful API for automation
- OIDC login — the proxy is an OpenID Connect client of an external SSO
(e.g.
theta42/sso-manager-node) - Direct LDAP lookups, independent of the OIDC flow
- Role-based access control (RBAC) — global admins, local groups, and
per-domain permissions (viewer/manager) via
/api/permissionand/api/group - Self-service API tokens (PATs) for scripting/CI without a browser session
- Unix socket-based host lookup for high-performance routing
Requirements
- Node.js 18+ (tested with 18.x, 20.x, 22.x)
- OpenResty (nginx with Lua support)
- Redis
- Modern Linux distribution (tested on Ubuntu 20.04+, Debian 11+)
- Inbound internet access for Let's Encrypt validation
- Root access (required for user management features)
Quick start
Three ways to run it, in order of how much it sets up for you:
1. As part of the unified stack (recommended)
theta-env composes this proxy with the
theta42 SSO Manager and generates
all the wiring (OIDC endpoints, LDAP bind, hostnames) from a single setup.env.
You enter your domain once and the proxy is registered as an OIDC client of the
SSO automatically:
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
cp setup.env.example setup.env # set CFG_DOMAIN to your domain
./setup.sh # generates ./config/, builds + bootstraps + starts both
See the theta-env README for the full first-run flow, DNS/port requirements, and backups.
2. Standalone, in Docker
The all-in-one image bundles OpenResty, the Node management app, and Redis. The
OIDC/LDAP/auth config is read from a bind-mounted ./config/proxy-secrets.js
(the OAuth client id/secret are filled in by your SSO's bootstrap, or you set
them yourself):
git clone https://github.com/theta42/proxy.git
cd proxy
mkdir -p config && chmod 700 config
cp secrets.js.example config/proxy-secrets.js
$EDITOR config/proxy-secrets.js # point oidc/ldap at your SSO + directory
docker compose up -d --build
The management UI comes up at http://127.0.0.1:3000 (bound to localhost; the
OpenResty front proxies it under TLS on 443). See DEPLOYMENT.md
and secrets.js.example for the full config shape.
3. Bare metal (Debian/Ubuntu)
An automated installer installs Node.js, OpenResty, Redis, the Lua modules, and the app, then symlinks the nginx config and starts a systemd service:
wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh | sudo bash
This installer will:
- Install Node.js 22.x
- Install OpenResty and required dependencies
- Install and configure Redis
- Set up SSL fallback certificates
- Install Lua dependencies (lua-resty-auto-ssl, luasocket)
- Clone and install the proxy application
- Configure systemd service
- Start the proxy service
Logs (Docker)
The all-in-one image runs OpenResty in the foreground and the Node app in the
background, both writing to the container's stdout/stderr. The proxy's nginx
access/error logs go to files (/var/log/nginx, on the proxy-logs volume),
so they do not show up in docker logs.
# App + OpenResty (stdout/stderr)
docker compose logs -f proxy
# or, by container name:
docker logs -f proxy
# nginx access / error logs (in-container files, not in docker logs)
docker compose exec proxy tail -f /var/log/nginx/access.log
docker compose exec proxy tail -f /var/log/nginx/error.log
# Recent context
docker compose logs --tail=200 --since=10m proxy
Manual Installation
For manual installation or other distributions, see the detailed steps below.
Recommended path:
ops/install.shis idempotent and safe to re-run — it symlinks the OpenResty/systemd config from the repo checkout, so updates stay in sync automatically. The manual steps below copy those same files instead of symlinking them, so they will not auto-track future changes toops/nginx_conf/orops/proxy.service— you'd need to re-copy them yourself after every update. Use the manual path only ifinstall.shdoesn't fit your distribution.
System Dependencies
Ubuntu/Debian:
apt install libpam0g-dev build-essential redis-server luarocks -y
Node.js 22.x:
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
NODE_MAJOR=22
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
apt update && apt install nodejs -y
OpenResty:
wget -O - https://openresty.org/package/pubkey.gpg | sudo gpg --dearmor -o /usr/share/keyrings/openresty.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/package/ubuntu $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/openresty.list
apt update && apt install openresty -y
Lua Dependencies:
luarocks install lua-resty-auto-ssl
luarocks install luasocket
SSL Configuration
Create fallback SSL certificates:
mkdir -p /etc/ssl/
openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
-subj '/CN=sni-support-required-for-valid-ssl' \
-keyout /etc/ssl/resty-auto-ssl-fallback.key \
-out /etc/ssl/resty-auto-ssl-fallback.crt
OpenResty Configuration
Configuration files are provided in ops/nginx_conf/:
nginx.conf- Main nginx configurationautossl.conf- Auto-SSL configuration for Let's Encrypt HTTP-01proxy.conf- Proxy server configuration with host lookuptargetinfo.lua- Lua module for host lookup via Unix socket
Copy these files to /etc/openresty/:
mkdir -p /etc/openresty/sites-enabled/
cp ops/nginx_conf/nginx.conf /etc/openresty/nginx.conf
cp ops/nginx_conf/autossl.conf /etc/openresty/autossl.conf
cp ops/nginx_conf/proxy.conf /etc/openresty/sites-enabled/000-proxy
cp ops/nginx_conf/targetinfo.lua /usr/local/openresty/lualib/targetinfo.lua
Application Setup
Clone and install:
cd /var/www
git clone https://github.com/theta42/proxy.git
cd proxy/nodejs
npm install
Create systemd service:
cp ops/proxy.service /etc/systemd/system/proxy.service
systemctl daemon-reload
systemctl enable proxy.service
systemctl start proxy.service
DNS Provider Configuration
For wildcard SSL certificates, configure a DNS provider via the web UI or API:
Supported providers:
- Cloudflare - Requires API token
- DigitalOcean - Requires API token
- PorkBun - Requires API key and secret API key
- DuckDNS - Free. Requires your account token and the list of subdomains
you've registered at duckdns.org (e.g.
myhostformyhost.duckdns.org). Good option if you don't own a domain — DuckDNS gives you one for free. Note DuckDNS only supports a single A/AAAA record and a single TXT record per domain (no arbitrary subdomains), which is enough for both dynamic DNS and DNS-01 wildcard certs but not for hosting other DNS records.
Once configured, create a wildcard host (e.g., *.example.com) and the system will automatically request and manage the DNS-01 challenge certificate.
Architecture
The system consists of three main components:
-
OpenResty/Nginx - Frontend proxy with Lua-based routing
- Handles SSL termination via lua-resty-auto-ssl
- Queries Node.js backend via Unix socket for host routing
- Proxies requests to configured backend servers
-
Node.js API - Backend management and control plane
- RESTful API for host/user/DNS management
- Wildcard SSL certificate orchestration
- Host lookup tree with wildcard matching
- User authentication and authorization
-
Redis - Data store (using model-redis ORM)
- Host configurations
- User accounts and tokens
- SSL certificate storage
- Domain and DNS provider configurations
Host Lookup System
The proxy supports sophisticated domain matching:
- Exact match:
example.commatches onlyexample.com - Single wildcard:
*.example.commatchessub.example.combut notdeep.sub.example.com - Double wildcard:
**.example.commatches any depth (sub.example.com,deep.sub.example.com, etc.) - Mixed wildcards:
api.*.example.commatchesapi.v1.example.com,api.v2.example.com, etc.
Priority: Exact match > Single wildcard > Double wildcard
Development
Running locally:
cd nodejs
npm install
npm run dev # Runs with nodemon for auto-reload
Running tests:
npm test # Run all tests
npm run test:unit # Run unit tests only
npm run test:integration # Run integration tests only
npm run test:watch # Watch mode for development
Tests use Node.js built-in test runner (requires Node 18+).
API Documentation
See API Documentation for complete API reference.
Contributing
Pull requests are welcome. The project uses GitHub Actions for CI/CD:
- Tests run automatically on all PRs
- All tests must pass before merging to master
- Tests run on Node.js 18.x, 20.x, and 22.x
License
MIT - See LICENSE file for details.
Project Structure
proxy/
├── nodejs/ # Node.js backend application
│ ├── bin/ # Entry point (www)
│ ├── conf/ # Configuration (base.js, environment overlays, secrets.js)
│ ├── controller/ # App-level wiring (pubsub, startup)
│ ├── migrations/ # One-off Redis data migration scripts
│ ├── models/ # Data models (Host, User, DNS providers)
│ ├── routes/ # API routes
│ ├── services/ # Background services (host lookup, scheduler)
│ ├── middleware/ # Express middleware
│ ├── utils/ # Utility functions
│ ├── public/ # Static web assets
│ ├── views/ # EJS templates
│ └── test/ # Test suite
├── ops/ # Operations and deployment
│ ├── nginx_conf/ # OpenResty configuration files
│ ├── install.sh # Automated installer
│ └── proxy.service # Systemd service definition
└── .github/workflows/ # CI/CD workflows