Open-source project
lyqht/mini-qr avatar
lyqht/mini-qr

MiniQR serves a prebuilt image behind nginx while package.json trails three releases

Create & scan cute qr codes easily 👾

2,406 stars292 forksTypeScriptGPL-3.0

At a glance

What is it?
MiniQR is a Vue 3 and Vite app that creates and scans QR codes, with batch CSV export, PWA install and a component library in Storybook. The compose file pulls a prebuilt image instead of building the Dockerfile sitting next to it, package.json still reads 0.30.2 against a v0.33.0 tag, and the /health route answers from nginx rather than from the app.
Who is it for?
MiniQR suits anyone who wants a self-hostable QR builder and scanner written in Vue, and the batch CSV export, the PWA install and the 30-plus language support are real capabilities rather than roadmap items. Check three things first.
Can I use it commercially?
Yes, with conditions. GPL-3.0 is a copyleft licence: if you distribute software that includes it, you must release that software's source code under the same licence. Running it internally without distributing it does not trigger that obligation.
Is it still maintained?
Yes. The repository last received commits 4 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 2, 2026, and from our analysis. They are not legal advice.

Editorial analysis

package.json reads 0.30.2 while the newest tag is v0.33.0

The version field in package.json is 0.30.2. The release stream has moved three times past it: v0.31.0 on 2026-06-05, v0.32.0 on 2026-07-29, and v0.33.0 on 2026-08-21. The last push is 2026-10-01, so this is not a stale mirror. It is a field in the source that nobody bumps.

That number is not decorative. The Dockerfile carries a dedicated build argument for it, VITE_APP_VERSION, repeated as an environment variable so Vite can inline the value into the bundle at build time. An operator building the image can therefore set the version the app reports independently of what package.json says. Anyone building without that argument gets whatever the source tree carries, which is three releases behind the tag.

The feature history in the README is more careful than the manifest. Capabilities are marked by the version that introduced them: batch data export at v0.9.0, scanning at v0.13.0, basic frame settings at v0.15.0, data templates at v0.16.0, and frame text inside batch export at v0.17.0. Those markers can be trusted, which is what makes the single stale number in package.json stand out.

The Docker build installs with npm in a project that declares pnpm

package.json declares [email protected] as its packageManager field, the tree ships pnpm-lock.yaml, .npmrc and a .husky directory, and the postinstall hook is husky install. The Dockerfile ignores all of that. It copies only the manifest and installs with a different client:

code
COPY package*.json ./
RUN npm install --frozen-lockfile
COPY . .
RUN npm run build

Two things follow from those three lines. The package*.json glob matches package.json alone, because no package-lock.json sits at the repository root, so there is no npm lockfile in the image for the frozen-lockfile flag to act on and the install resolves against the registry. The flag spelling itself belongs to the other package manager. The lockfile this project actually maintains is never consulted by the image build.

The ordering matters too. npm install runs the postinstall hook, and that hook is husky install, which expects a .husky directory to be present. The COPY . . line that would bring .husky into the image comes afterwards, so the hook executes against a workspace that does not yet hold the directory it wants.

The health endpoint answers from nginx, not from the app

The compose file puts an nginx proxy in front of the application and writes its configuration inline. The upstream block points at the app container on port 8080, which lines up with the Dockerfile, where the production stage installs serve and runs it against the dist directory on port 8080. Ordinary paths are proxied to that upstream.

The health path is not. It is terminated inside nginx itself:

code
              location /health {
                  access_log off;
                  return 200 "healthy\n";
                  add_header Content-Type text/plain;
              }

A return directive short-circuits the request, so no proxy is attempted and the application container is never consulted. A monitor polling that path receives a green 200 from nginx whether or not the mini-qr container is running, whether or not a bundle exists in its dist directory, and whether or not the upstream is reachable at all.

Both services carry restart unless-stopped, and only the proxy publishes a port, so the app is addressed exclusively over the internal bridge network. That makes the bypassed health check the single endpoint an operator will reach for, which is exactly why it reads as a readiness signal when it is not one.

compose pulls a published image and forwards none of the build variables

The compose file does not build anything. The application service is four keys long:

code
  mini-qr:
    image: ghcr.io/lyqht/mini-qr:latest
    container_name: mini-qr
    restart: unless-stopped
    networks:
      - mini-qr-network

There is no build section, so the Dockerfile in this repository is never exercised by the compose path. There is also no environment key and no env_file key, which means every value in the sample environment file is inert under compose. Those values are all VITE-prefixed, and a VITE-prefixed variable is consumed by the bundler at build time, not by the served bundle at container start. Passing them at runtime would achieve nothing even if compose accepted them.

The Dockerfile declares ten VITE build arguments and repeats each one as an environment variable so the bundler can inline it. None of the ten reach the compose path, because the published image was built without them and compose never rebuilds it. What compose does pin down is the tag, and the tag is latest, which is unpinned.

BASE_PATH carries three different defaults across three files

Deploying under a subpath is the one thing the sample environment file explains in prose. It sets BASE_PATH to /mini-qr and adds a comment saying the default is ./, relative paths, which it claims work at any sub-path without configuration.

The Dockerfile states a different default:

code
ARG BASE_PATH=/
ENV BASE_PATH=${BASE_PATH}

A root-relative default of / is not the same thing as the relative ./ the sample file describes, so the two documents disagree about what happens with no configuration at all. A third input exists as well: an .env.development file at the repository root, which developers pick up locally and which has no bearing on a container build.

Which default applies to you depends entirely on how you obtained the running app. Build the image yourself and the / default applies unless you pass the argument. Pull the published image through compose and the effective value is whatever was in effect when that image was built, with nothing in compose able to override it. The full walkthrough, covering Docker setup, environment variables, custom presets and deployment scenarios, sits in SELF_HOSTING.md, which the README links and does not summarize.

Setting VITE_FIELDS_VISIBLE also puts the app into Simple mode

Two build variables control how much of the configuration interface a self-hoster sees, and they interact in a way the key names do not advertise.

The first is VITE_QR_CREATE_SIMPLE_FULL_MODE_TOGGLE, which defaults to false. The sample file explains that the Simple/Full toggle and the Customize fields button stay hidden by default so self-hosted deployments are not exposed to them unless someone opts in.

The second is VITE_FIELDS_VISIBLE, which takes a comma-separated list of field keys. Setting it does two things at once. It shows only those fields, and it starts the app in Simple mode instead of the full configuration view. The comment gives dotsColor and frameText as the example and says to leave the value empty for the normal full-configuration experience.

So the variable named for field visibility is also the switch that removes the full editor. An operator who sets a field list in order to tighten the interface, and who then finds the full configuration gone, has to locate and flip the other variable, whose default is to hide it entirely.

Two decoding libraries and one encoder share one dependency list

The dependency list carries three QR libraries. qrcode-generator 2.0.4 handles creation. html5-qrcode 2.3.8 and qr-scanner 1.4.2 both handle decoding, and they sit side by side in the same dependencies block.

Scanning is described in a single line: use the camera or upload an image, with detection for URLs, emails, phone numbers and WiFi credentials among others. One feature, two decoding libraries, and nothing at the top level of the tree says which one backs the camera path and which one backs the upload path. On a phone that split is not cosmetic, since the two reach the camera through different mechanisms and can prompt differently.

The rest of the list is easier to place. marked 15.0.12 parses Markdown, jszip 3.10.1 produces the archive behind batch export, and file-saver 2.0.5 handles downloads. Data templates cover text, URLs, emails, phone numbers, SMS, WiFi credentials, vCards, locations, calendar events and EPC QR for SEPA payment and GiroCode, and the error correction level is exposed as a control because it trades the space available for the logo against the data that can be encoded.

The build arguments and the sample environment file do not match

Set the two documents beside each other and the gaps appear at both ends.

On one side, the sample environment file offers VITE_ENABLE_ANALYTICS=false, and the Dockerfile never declares VITE_ENABLE_ANALYTICS as a build argument. There is no ARG line and no ENV line for it anywhere in the build stage, so setting it in a sample copy has no route into a container image.

On the other side, VITE_APP_VERSION is declared and forwarded in the Dockerfile and has no entry in the sample environment file at all. An operator reading only that file would not know the variable exists, even though it is the one that fixes the version the app reports.

The same file opens with a pair of maintainer credentials, CROWDIN_PERSONAL_TOKEN and CROWDIN_PROJECT_ID, placed above the application settings with nothing marking the boundary. Those belong to the translation workflow rather than to the app: the tree carries a locales directory, a Crowdin-specific ignore file, a sync-i18n script and a DeepL translation script, and the README credits translation contributors and claims 30 or more languages. Release tooling credentials and runtime configuration occupy one list, separated by nothing but reading order.

Editorial conclusion

MiniQR suits anyone who wants a self-hostable QR builder and scanner written in Vue, and the batch CSV export, the PWA install and the 30-plus language support are real capabilities rather than roadmap items. Check three things first. The version in package.json is three releases behind the newest tag, so do not trust it for reporting or audit trails. The compose path pulls ghcr.io/lyqht/mini-qr:latest without pinning a tag and without forwarding any of the ten VITE build variables, so treat that image as a moving target and build it yourself if you need control over the configuration. And the /health endpoint is answered by nginx, so it will report healthy while the application behind it is down. GPL-3.0 covers the repository, and SELF_HOSTING.md holds the deployment detail the README defers.

Frequently asked questions

What is MiniQR built on, and what does it do?

MiniQR is a Vue 3 and Vite application for creating and scanning QR codes, using TypeScript, Tailwind CSS 3.4.19 and vue-i18n. It exports to PNG, JPG, SVG, ASCII and Unicode, imports a CSV for batch export, installs as a PWA, and scans by camera or by uploaded image.

Does MiniQR publish a Docker image, and what does its compose file run?

The compose file runs a prebuilt image, ghcr.io/lyqht/mini-qr:latest, with no build section and no environment or env_file keys, alongside an nginx:alpine proxy that publishes port 80 and forwards to mini-qr:8080 on a bridge network.

Does the MiniQR /health endpoint report whether the application is up?

No. That location is handled inside the nginx configuration with a return directive that answers 200 healthy directly, without proxying to the application container, so it stays green while the app behind it is down.

Can MiniQR be served from a subpath, and where is that configured?

The Dockerfile accepts a BASE_PATH build argument defaulting to /, and the sample environment file sets BASE_PATH to /mini-qr with a comment describing ./ as the default. Full self-hosting instructions, including Docker setup, environment variables and custom presets, are in SELF_HOSTING.md.

Do the values in the MiniQR sample environment file apply under docker compose?

No. The compose service sets only the image, container name, restart policy and network, so the VITE-prefixed values never reach it, and those values are read by the bundler at build time rather than by the served bundle at container start.

Official sources

  1. License: GPL-3.0
  2. lyqht/mini-qr on GitHub
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/lyqht-mini-qr.svg)](https://hysenlabs.com/projects/lyqht-mini-qr)