Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6

- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v4

- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
username: mesudip
password: ${{ secrets.DOCKERHUB_TOKEN }}
Expand All @@ -51,7 +51,7 @@ jobs:

- name: Build and push for tags
if: startsWith(github.ref, 'refs/tags/')
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
file: Dockerfile
context: .
Expand All @@ -63,7 +63,7 @@ jobs:

- name: Build and push for main branch
if: github.ref == 'refs/heads/master'
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
file: Dockerfile
context: .
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish-python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.12"

Expand Down
112 changes: 106 additions & 6 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,25 @@ on:
jobs:
test:
runs-on: ubuntu-latest
concurrency:
group: test-${{ github.event.pull_request.head.sha || github.sha }}
cancel-in-progress: true
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: '3.12'

- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v4

- name: Install dependencies
run: |
Expand All @@ -41,16 +44,113 @@ jobs:

- name: Run tests
run: |
pytest --cov --cov-branch --junitxml=junit.xml -o junit_family=legacy
pytest --cov --cov-branch --cov-report=xml:coverage.xml --junitxml=junit.xml -o junit_family=legacy

- name: Write test summary
if: ${{ always() }}
run: |
python - <<'PY'
import os
import sys
import xml.etree.ElementTree as ET
from pathlib import Path

summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if not summary_path:
sys.exit(0)

junit_path = Path("junit.xml")

def md_escape(value):
return str(value).replace("|", "\\|").replace("\n", "<br>")

lines = ["## Test summary", ""]

if not junit_path.exists():
lines.extend([
"No `junit.xml` test report was generated.",
"",
])
else:
root = ET.parse(junit_path).getroot()
suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite"))
if not suites:
suites = [root]

totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0, "time": 0.0}
for suite in suites:
totals["tests"] += int(suite.attrib.get("tests", 0))
totals["failures"] += int(suite.attrib.get("failures", 0))
totals["errors"] += int(suite.attrib.get("errors", 0))
totals["skipped"] += int(suite.attrib.get("skipped", 0))
totals["time"] += float(suite.attrib.get("time", 0.0))

passed = totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"]
lines.extend([
"| Total | Passed | Failed | Errors | Skipped | Duration |",
"| ---: | ---: | ---: | ---: | ---: | ---: |",
f"| {totals['tests']} | {passed} | {totals['failures']} | {totals['errors']} | {totals['skipped']} | {totals['time']:.2f}s |",
"",
"### Metadata",
"",
"| Key | Value |",
"| --- | --- |",
f"| Python | {md_escape(sys.version.split()[0])} |",
f"| Runner OS | {md_escape(os.environ.get('RUNNER_OS', 'unknown'))} |",
f"| Event | {md_escape(os.environ.get('GITHUB_EVENT_NAME', 'unknown'))} |",
f"| Ref | {md_escape(os.environ.get('GITHUB_REF_NAME', 'unknown'))} |",
f"| SHA | `{md_escape(os.environ.get('GITHUB_SHA', 'unknown'))}` |",
f"| Actor | {md_escape(os.environ.get('GITHUB_ACTOR', 'unknown'))} |",
"",
])

failed_cases = []
for case in root.iter("testcase"):
issue = case.find("failure")
if issue is None:
issue = case.find("error")
if issue is not None:
failed_cases.append((case.attrib, issue))

if failed_cases:
lines.extend([
"### Failed tests",
"",
"| Test | Type | Message |",
"| --- | --- | --- |",
])
for attrs, issue in failed_cases[:25]:
classname = attrs.get("classname", "")
name = attrs.get("name", "unknown")
test_name = f"{classname}.{name}" if classname else name
message = issue.attrib.get("message", "").strip() or (issue.text or "").strip().splitlines()[0:1]
if isinstance(message, list):
message = message[0] if message else ""
lines.append(f"| `{md_escape(test_name)}` | {md_escape(issue.tag)} | {md_escape(message[:300])} |")
if len(failed_cases) > 25:
lines.append(f"| ... | ... | {len(failed_cases) - 25} more failing tests omitted from summary |")
lines.append("")

with open(summary_path, "a", encoding="utf-8") as summary:
summary.write("\n".join(lines))
summary.write("\n")
PY

- name: Upload coverage reports to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage.xml
disable_search: true
fail_ci_if_error: false

- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: junit.xml
disable_search: true
report_type: test_results
fail_ci_if_error: false
9 changes: 4 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# mesudip/python-nginx:alpine is merge of official python and nginx images.
FROM mesudip/python-nginx
# This provides nginx and python together in a container
FROM ghcr.io/mesudip/python-nginx:py3.13.13-nginx1.30.1-alpine3.23

RUN pip install --upgrade pip

Expand All @@ -11,20 +11,19 @@ RUN apk --no-cache add openssl && \
gcc libc-dev openssl-dev linux-headers libffi-dev && \
pip install --no-cache-dir -r /requirements.txt && \
rm -f /requirements.txt && apk del .build-deps && \
ln -s /app/getssl /bin/getssl && ln -s /app/verify /bin/verify && \
ln -s /app/getssl /bin/getssl && ln -s /app/verify /bin/verify && ln -s /app/reload /bin/reload && \
mv /docker-entrypoint.sh /nginx-entrypoint.sh && \
ln -s /app/docker-entrypoint.sh /docker-entrypoint.sh
RUN rm -rf /var/log/nginx/* && chown nginx:nginx /var/log/nginx && truncate -s 0 /etc/nginx/conf.d/default.conf
COPY ./vhosts_template/nginx.conf /etc/nginx/nginx.conf
ARG LETSENCRYPT_API="https://acme-v02.api.letsencrypt.org/directory"
ENV LETSENCRYPT_API=${LETSENCRYPT_API} \
CHALLENGE_DIR=/etc/nginx/challenges/ \
DHPARAM_SIZE=2048 \
CLIENT_MAX_BODY_SIZE=1m \
NGINX_WORKER_PROCESSES=auto \
NGINX_WORKER_CONNECTIONS=65535 \
SSL_DIR=/etc/nginx/ssl \
DEFAULT_HOST=true \
VHOSTS_TEMPLATE_DIR=/app/vhosts_template
WORKDIR /app
COPY . /app/
COPY . /app/
40 changes: 36 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,13 @@ Control the default behavior of `nginx-proxy`:
| `NGINX_WORKER_CONNECTIONS` | `65535` | Max connections per worker. |
| `CERT_RENEW_THRESHOLD_DAYS` | `30` | By default certificates are renewed when they have <=30 days remaining. |
| `ENABLE_IPV6` | `false` | Enable IPv6 support on nginx. |
| `DOCKER_SWARM` | `ignore` | Treats every container like local by defeault. Set `enable` for Swarm support, `strict` for Swarm-only or`exclude` to not include swarm containers |
| `DOCKER_SWARM` | `ignore` | Controls Docker Swarm discovery. Supported values are `ignore`, `exclude`, `enable`, `prefer-local`, and `strict`; see [Docker Swarm Support](#docker-swarm-support-preview). |
| `SWARM_DOCKER_HOST` | - | URL of the Swarm manager socket (e.g., `tcp://manager:2375`). |
| `CERTAPI_URL` | - | External Certificate API URL. |
| `CERTAPI_BATCH_DOMAINS` | `true` | When using `CERTAPI_URL`, request safe domain batching (`batch_domains=true`) to avoid recursive domain-order errors. |
| `CHALLENGE_DIR` | `/etc/nginx/challenges/` | Base directory for acme challenge store, when requesting certificates with acme. `.well-known/acme-challenge` folder lives inside this.|
| `CLOUDFLARE_API_KEY_KEY*` | - | Cloudflare api keys to issue DNS certificates.|
| `BACKEND_START_GRACE_SECONDS` | `10` | Delay registering containers without a Docker healthcheck so crashing backends dont' result reload|


## Virtual Hosts
Expand Down Expand Up @@ -138,10 +139,39 @@ Format: `STATIC_VIRTUAL_HOST=domain.com->http://192.168.0.1:8080`.
**Note** Be aware that if domain as target, nginx will crash if DNS resolution fails.

## Docker Swarm Support [Preview]
Enable swarm mode by setting `DOCKER_SWARM` to `enable` (local & swarm) or `strict` (swarm only).
If current node is not manager, set `SWARM_DOCKER_HOST=tcp://manager:2375`.

**Warning** : Automatic exposed port detection will not work when swarm support is enabled. You must explicitly set port on the `VIRTUAL_HOST` or set `VIRTUAL_PORT` on the container.
**Warning** : Automatic exposed port detection will not work when swarm support is enabled. You must explicitly set port on the `VIRTUAL_HOST`.


Docker Swarm discovery is controlled by the `DOCKER_SWARM` environment variable on the `nginx-proxy` container.

| `DOCKER_SWARM` value | Local containers | Swarm services | Use case |
| :--- | :--- | :--- | :--- |
| `ignore` | Included | Not discovered | Default Docker-only behavior. Swarm task containers are treated like standalone containers if they are visible on the local Docker socket. |
| `exclude` | Included | Not discovered | Docker-only discovery while explicitly ignoring containers that belong to Swarm services. |
| `enable` | Included | Included | Mixed mode. Use this when `nginx-proxy` should route both standalone containers and Swarm services. |
| `prefer-local` | Included | Included | Mixed Swarm mode that prefers healthy local task containers and keeps the service VIP as a fallback. |
| `strict` | Excluded | Included | Swarm-only mode. Use this when `nginx-proxy` should route only Swarm services. |

`ignore` is the default and does not require the Docker node to be in Swarm mode. In this mode, `nginx-proxy` only reads the normal Docker container API. If a Swarm task container is visible on the local Docker socket, it can be registered as if it were a regular container.

`exclude` still uses only the local Docker container API, but skips containers that have Swarm service labels. This is useful when the same Docker host runs standalone containers and Swarm services, but this proxy instance should only manage standalone containers.

`enable` reads both local containers and Swarm services. Standalone containers are discovered from the local Docker socket. Swarm services are discovered from the Swarm manager API, and task containers are skipped so each service is registered once.

`prefer-local` reads both local containers and Swarm services, but local Swarm task containers are also discovered from the local Docker socket. When a route has local containers and the Swarm service VIP, nginx sends normal traffic to the local containers and marks the service VIP as a `backup` upstream server. If no local container is available, the service VIP is used normally. Existing container healthcheck and `BACKEND_START_GRACE_SECONDS` behavior still applies before local containers are registered.

`strict` reads only Swarm services. Local standalone containers are ignored, and Swarm task containers are also ignored. This is the mode to use when this proxy instance is dedicated to Swarm routing.

For `enable`, `prefer-local`, and `strict`, the Swarm API client must be connected to a manager node because Docker only allows managers to list services. If `nginx-proxy` is running on a worker node, set `SWARM_DOCKER_HOST` to a reachable manager Docker API endpoint:

```bash
-e DOCKER_SWARM=enable \
-e SWARM_DOCKER_HOST=tcp://manager:2375
```

If `SWARM_DOCKER_HOST` is not set, the local Docker socket is used for both local containers and Swarm services. When `SWARM_DOCKER_HOST` is set, `nginx-proxy` uses the local Docker socket for standalone containers and the remote manager socket for Swarm services. If the local Docker socket cannot be reached but `SWARM_DOCKER_HOST` is set, `nginx-proxy` switches to `strict` mode and uses only the remote Swarm manager.


## Advanced Features
### Redirection
Expand Down Expand Up @@ -220,6 +250,8 @@ docker exec nginx-proxy verify www.example.com ## check if request routes back t

docker exec nginx-proxy getssl www.example.com example.com www2.example.com # issue certificate

docker exec nginx-proxy reload # rescan Docker state and reload nginx config

```

## 🚀 Roadmap
Expand Down
2 changes: 1 addition & 1 deletion dev-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
pytest==8.2.2
pytest==9.0.3
pytest-cov
websocket-client
python-dotenv
2 changes: 0 additions & 2 deletions docker/entry-point.sh

This file was deleted.

76 changes: 0 additions & 76 deletions docker/nginx.conf

This file was deleted.

Loading
Loading