Skip to content

Compress proxied responses in the shipped NGINX - #14

Open
r0ny123 wants to merge 6 commits into
danielplohmann:mainfrom
r0ny123:fix/nginx-gzip
Open

r0ny123 wants to merge 6 commits into
danielplohmann:mainfrom
r0ny123:fix/nginx-gzip

Conversation

@r0ny123

@r0ny123 r0ny123 commented Sep 15, 2026

Copy link
Copy Markdown

This is stacked on #13 and will be rebased once that merges; everything before 94436d0 belongs to that pull request, so 94436d0 is the commit to read. The nginx.conf this repository ships never enabled gzip, so every response NGINX proxied from MCRITweb went over the wire uncompressed. On a running stack the login page is 15115 bytes of HTML and the bundled jquery.js is 89501 bytes of JavaScript, both of them the kind of text that compresses well, and both were served in full to every client on every uncached request.

The change enables gzip in the http block, so it applies to both site configurations rather than being repeated in each. gzip_proxied any is what makes it take effect at all here: every response this NGINX serves comes from an upstream, and the default only compresses a narrow set of proxied responses. gzip_vary adds the Accept-Encoding vary header so a cache in front cannot serve a compressed body to a client that did not ask for one, and gzip_min_length 1024 keeps small responses out of it, where the header overhead and the CPU are not repaid. The gzip_types list names CSS, JavaScript, JSON and SVG; HTML is compressed by default and needs no entry, but nothing else is eligible without being listed.

Measured against the stack built from this branch, with the containers running and NGINX in front: /login goes from 15115 to 5137 bytes and /static/jquery.js from 89501 to 36046 bytes, both answering with Content-Encoding: gzip and Vary: Accept-Encoding, and identical requests without Accept-Encoding still return the uncompressed bodies at their original sizes. docker compose config -q is clean on both compose files.

The compression level is left at the default of 1, which is where the ratio per unit of CPU is best for on-the-fly compression of dynamic pages; a deployment serving mostly static assets could raise it, but that is a tuning decision rather than a default. There is no gzip_static, since nothing here pre-compresses assets, and no Brotli, which the stock nginx image does not carry. MCRIT's API is behind the same NGINX only when reached through the web frontend, so the JSON entry in the type list matters mostly for MCRITweb's own XHR responses.

Closes #9

This repository releases by moving the pins in .env and writing a dated
CHANGELOG.md entry, and since the Keep a Changelog adoption its own changes
have somewhere to go - but nothing made anyone write them down. A pull
request that changes what a deployment is built from now has to touch
CHANGELOG.md or carry the no-changelog label. RELEASING.md says how a bump
is done, what a release is here, and where this repository sits in the
ecosystem's release order.

CI pins actions/checkout to a commit SHA, drops the checkout's credentials
from the runner, and runs with a read-only token. Dependabot watches the
pinned actions and the Ubuntu base images of both Dockerfiles, so a base
image bump arrives as a reviewable change rather than as whatever the tag
resolved to on rebuild day.
…steps at real documentation

CHANGELOG.md already says CI changes belong under [Unreleased], but the
changelog check exempted anything under .github/. Add it to the pattern.

RELEASING.md told the bumper to preserve deviations "the README documents";
the README documents none. Point at the rationale comments in config/ and the
changelog instead, and give the exact command for the no-changelog label the
check depends on, which the repository does not yet have.

The dependabot comment promised an Ubuntu LTS bump; the docker updater offers
any newer tag, so say base-image bump.
Images
- Build in one apt layer with the lists removed, without apt-get upgrade,
  with shallow clones and a pip cache mount. Drop gcc-multilib: nothing needs
  a 32-bit toolchain and the package does not exist on arm64, so the images
  now build on Apple Silicon. Replace locales-all with locales plus
  locale-gen for en_US.UTF-8. Add .dockerignore files.
- mcritweb no longer bakes FLASK_DEBUG=1 into the image and is served by
  gunicorn (installed explicitly, requirements.txt does not list it) instead
  of the Flask development server.
- The mcrit image is built once and shared by server and worker.

Runtime
- Entry scripts run under set -eu and exec their process; the mcrit services
  get init: true so SIGTERM reaches Python, and docker compose stop returns
  in well under a second instead of the ten-second timeout.
- Healthchecks on mongodb, mcrit-server and mcritweb, depends_on conditions
  instead of sleep 1, restart: unless-stopped in production, ${VAR:?} guards
  on every interpolated tag, config and nginx mounts read-only.
- MongoDB 5.0 (end of life) moves to 8.0; the README documents the stepped
  upgrade an existing storage/mongodb needs. mongod logs to stdout.
- nginx pinned through NGINX_TAG instead of nginx:latest; the TLS config
  serves TLS 1.2 and 1.3 with the Mozilla intermediate ciphers, drops DHE and
  ssl_dhparam, and no longer requests HSTS preload.
- nginx/ssl/*.pem are untracked (placeholders kept as .example) so a filled-in
  private key cannot be committed by accident.

Scripts and CI
- build.sh uses docker compose v2; reset.sh and clone_repositories.sh get
  shebangs, set -euo pipefail and no cd juggling; test_build.sh drops
  --no-cache and builds the compose image names.
- CI builds both images with Buildx and a GitHub Actions layer cache, reads
  the tags from .env, and has a timeout.

Verified locally: both images build, the stack comes up healthy, the web UI
answers through nginx and gunicorn, the mcrit API reports 1.9.0, and the
test entrypoint passes the 191 upstream unit tests.
Both images carried their build toolchain into the runtime and ran everything
as root: build-essential, python3-dev, git and the pip cache stayed in the
final layer, and a compromise of a web request had uid 0 inside the container.
Split each Dockerfile into a builder stage that installs into /opt/venv and a
runtime stage that copies the venv and the source across and adds only what
running needs. The MCRIT image goes from 1.19 GB to 714 MB and the MCRITweb
image from 1.28 GB to 787 MB, and both end on uid 10001.

Running unprivileged makes ./storage/mcritweb, a host bind mount, something
the operator has to hand to that uid once; both MCRITweb entry scripts now
check the directory and exit with the chown command in the message instead of
failing later inside Flask. entry_test.sh no longer pip-installs pytest at
startup, which an unprivileged user cannot do: the image carries pytest and
the script runs it directly, so it needs no make either.

The base images are pinned by digest rather than by the 24.04 tag, so a
rebuild cannot silently pick up a different Ubuntu, and both images carry OCI
source, version and licence labels plus a .git-revision file recording the
upstream commit they were built from - a label cannot hold a value resolved
during the build.

On the compose side, every service gets no-new-privileges, a json-file log cap
of 5 files of 50 MB through one x-logging anchor so a chatty container cannot
fill the host's disk, and MCRIT_AUTH_TOKEN passed through to the server and
the worker. CI gains a lint job running hadolint over both Dockerfiles,
docker compose config over both compose files and shellcheck over every entry
script; .hadolint.yaml records why apt and pip version pinning are not
enforced here. The README gains a production checklist covering TLS, the API
token pairing with MCRITweb, the instance directory ownership and backups.
The README had grown into a mix of a project pitch, a partial setup guide and
three version-specific maintenance notes, while the things a first deployment
actually needs - the uid the containers run as, where the version pins live,
what to change for TLS - were scattered or absent. It also still used
docker-compose and duplicated release information that CHANGELOG.md owns.

Restructure it in the order an operator meets the deployment: what is run and
where the pins are, requirements, first deployment, configuration, production,
upgrading, development mode, and a pointer to CHANGELOG.md for the version
history. The MongoDB 5.0 stepping procedure stays because it is a
one-directional host-level operation; the MCRIT 1.7.0 migration note and the
SMDA fingerprint note are dropped in favour of the changelog entry and the
upstream migration guide, which are where a release states what it requires.
Every command is a fenced block using docker compose, and every path,
variable and script name is checked against the tree.
The shipped nginx.conf never enabled gzip, so every response NGINX proxied
from MCRITweb went over the wire uncompressed: the login page at 15115 bytes
and the bundled jquery.js at 89501 bytes, both highly compressible text.

Enable gzip in the http block, with gzip_vary so caches key on the encoding,
gzip_proxied any because every response here is a proxied one, a 1 KB floor so
small responses are not paid for, and an explicit gzip_types list covering
HTML, CSS, JavaScript, JSON and SVG. HTML is compressed by default and needs
no entry, but listing text/plain and the rest is what makes the static assets
and the API's JSON eligible.

Measured against the running stack: /login drops from 15115 to 5137 bytes and
/static/jquery.js from 89501 to 36046 bytes, and both responses come back with
Content-Encoding: gzip and Vary: Accept-Encoding.

Closes danielplohmann#9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The shipped nginx enables no compression, so every proxied response goes out uncompressed

1 participant