Note
See also: Hatchards - A Go reimplementation of hashcards featuring a web application, deck selection, statistical graphs, Git mirroring with post-mirror hook execution, authentication, and an SQLite viewer.
On-demand reverse proxy launcher for hashcards
An on-demand reverse proxy launcher layered on top of hashcards. hashwrap starts a hashcards process the moment a browser request arrives and forwards traffic as soon as it is ready. Multiple decks can be served simultaneously under different URL paths, with automatic PWA support, dark mode, and mobile layout injection.
It runs entirely from a pre-built Alpine-based 22.5 MB container provided on GitHub, requiring no additional build steps.
After pulling the container, you still need to provide a compose.yaml and configure the access URL in config.json.
| Feature | Description |
|---|---|
| On-demand launch | Starts hashcards automatically on the first request. Restarts on the next request if the process has exited |
| Multi-route | Manages independent hashcards instances per path |
| Transparent URL rewriting | Rewrites http://127.0.0.1:{port}/... URLs generated by the backend to the public-facing URL |
| PWA support | Serves manifest.json / sw.js / icons / favicon and injects PWA tags into every HTML response |
| Dark mode | Applies a CSS invert trick to every page — no white flash |
| Mobile layout | Controls font sizes via @media (max-width: 768px) using plain HTML tag selectors |
| Redirect correction | Rewrites Location headers to include the correct path prefix |
Clone the repository to get the example config and deck:
git clone https://github.com/asano69/hashcards-wrapper.git
cd hashcards-wrapperThen update config.json so that public_base_url matches the URL you will use to access it from your browser (e.g. http://localhost:3001).
Start with Docker Compose:
# compose.yaml
services:
hashwrap:
image: ghcr.io/asano69/hashcards-wrapper:latest
ports:
- "3001:3000"
volumes:
- ./config.json:/app/config.json:ro
- ./example:/app/data
- ./extras/menu.sh:/app/menu.sh:ro # optional
restart: unless-stoppeddocker compose up -d{
"public_base_url": "http://localhost:3001",
"listen": ":3000",
"pwa_dir": "/app/pwa",
"style": {
"dark_brightness": 0.75,
"mobile_root_size": "100%",
"mobile_h1_size": "1.6rem",
"mobile_p_size": "1.2rem"
},
"startup_timeout_sec": 10,
"routes": [
{
"path": "/",
"command": "hashcards drill --host=0.0.0.0 --port={port} --open-browser=false"
},
{
"path": "/math",
"command": "hashcards drill --host=0.0.0.0 --port={port} --open-browser=false --from-deck=Mathematics"
},
{
"path": "/art",
"command": "hashcards drill --host=0.0.0.0 --port={port} --open-browser=false --from-deck=Art"
},
{
"path": "/audio",
"command": "hashcards drill --host=0.0.0.0 --port={port} --open-browser=false --from-deck=Audio"
},
{
"path": "/menu",
"command": "/app/menu.sh 8001",
"port": 8001
}
]
}| Field | Type | Default | Description |
|---|---|---|---|
listen |
string | ":8080" |
Address hashwrap binds to (e.g. ":3000", "0.0.0.0:3000") |
startup_timeout_sec |
int | 10 |
Seconds to wait for hashcards to start listening |
public_base_url |
string | auto-detected | Externally reachable base URL. Must include the scheme (e.g. "https://hashcards.example.com", "http://localhost:3000"). Required when running behind a reverse proxy |
pwa_dir |
string | — | Directory containing PWA static files. Leave empty to disable PWA |
style |
object | — | CSS injection settings (see below) |
routes |
array | — | Route definitions (see below) |
| Field | Type | Default | Description |
|---|---|---|---|
dark_brightness |
float | 0.70 |
CSS brightness() value applied in dark mode. Lower = darker text. Recommended range: 0.60–0.85 |
mobile_root_size |
string | "90%" |
html font-size on mobile, proportionally scaling all rem-based text |
mobile_h1_size |
string | — | <h1> font-size on mobile. Falls back to mobile_root_size if omitted |
mobile_p_size |
string | — | <p> font-size on mobile. Falls back to mobile_root_size if omitted |
| Field | Type | Default | Description |
|---|---|---|---|
path |
string | required | URL path to match (e.g. "/", "/math") |
command |
string | required | Command to run. {port} is replaced with the actual port number |
port |
int | 0 (auto) |
Port for hashcards to bind to. Omit or set to 0 to auto-assign a free port |
extras/menu.sh is a minimal index page that lists all configured routes as clickable links.
Mount it and register it as a route to enable it:
volumes:
- ./extras/menu.sh:/app/menu.sh:ro{
"path": "/menu",
"command": "/app/menu.sh 8001",
"port": 8001
}The port passed to menu.sh must match the port field in the route definition.
The page reads config.json at startup and builds the link list automatically.
Always set public_base_url when running behind a reverse proxy. The scheme must be included — hashwrap cannot infer whether the public endpoint is HTTP or HTTPS.
server {
listen 443 ssl;
server_name hashcards.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}{
"public_base_url": "https://hashcards.example.com",
"listen": ":3000"
}Place the following files in the directory specified by pwa_dir:
/app/pwa/
manifest.json
sw.js
icon-192.png
icon-512.png
favicon.ico
When enabled, hashwrap:
- Serves
/manifest.json,/sw.js,/icon-192.png,/icon-512.png, and/favicon.icodirectly - Injects PWA meta tags and a Service Worker registration script just before
</head>in every HTML response - Injects
<link rel="icon">tags into every HTML response so that the favicon resolves correctly regardless of the current path (e.g./art/,/math/)
Mounting pwa_dir as a Docker volume is the recommended way to
customise icons without rebuilding the image — including
platform-specific assets for iOS and Android home screens.
Default icons (black background, white # glyph) can be regenerated with:
make icon
Requires ImageMagick.
Browser → hashwrap (:3000)
│
├─ / → hashcards process A (auto-assigned port)
├─ /math → hashcards process B (auto-assigned port)
├─ /art → hashcards process C (auto-assigned port)
└─ /audio → hashcards process D (auto-assigned port)
A process is started only when it is not already running and is reused for all subsequent requests. If a process exits, it is restarted on the next incoming request.
hashwrap | 2026/03/26 21:36:14 hashwrap listening on :3000
hashwrap | 2026/03/26 21:36:14 PWA enabled: serving static files from /app/pwa
hashwrap | 2026/03/26 21:36:14 route registered: / -> hashcards drill --host=0.0.0.0 --port={port} --open-browser=false (strip_prefix=false)
hashwrap | 2026/03/26 21:36:14 route registered: /math -> hashcards drill --host=0.0.0.0 --port={port} --open-browser=false --from-deck=Mathematics (strip_prefix=true)
hashwrap | 2026/03/26 21:36:14 route registered: /art -> hashcards drill --host=0.0.0.0 --port={port} --open-browser=false --from-deck=Art (strip_prefix=true)
hashwrap | 2026/03/26 21:36:14 route registered: /audio -> hashcards drill --host=0.0.0.0 --port={port} --open-browser=false --from-deck=Audio (strip_prefix=true)
hashwrap | 2026/03/26 21:36:24 -> GET /art
hashwrap | 2026/03/26 21:36:24 [/art] starting: hashcards drill --host=0.0.0.0 --port=38367 --open-browser=false --from-deck=Art
hashwrap | 2026/03/26 21:36:25 [/art] ready (port=38367)
hashwrap | 2026/03/26 21:36:25 -> GET /art/file/thetempest.webp
hashwrap | 2026/03/26 21:36:25 [/art] process running (port=38367), reusingSub-path routes (/math, /art, etc.) automatically strip their prefix before forwarding requests to the backend.
hashcards always expects to run at the root path and has no awareness of the prefix.
This applies to incoming requests. Requests to sub-path routes always have their prefix stripped before being forwarded to hashcards. hashcards expects to run at the root path and would return 404 errors if the prefix were forwarded unchanged.
| Request from browser | Forwarded to backend |
|---|---|
/art/file/assets/thetempest.webp |
/file/assets/thetempest.webp |
/math/file/assets/diagram.png |
/file/assets/diagram.png |
Routes mounted at / forward paths unchanged.
This applies to outgoing responses.
hashcards embeds self-referencing URLs in its responses (e.g. http://127.0.0.1:8000/file/img.jpg) that are unreachable by the browser. hashwrap reads the response body (HTML / CSS / JS / JSON) and rewrites any such URL to the public-facing address.
http://127.0.0.1:8000/file/img.jpg
↓
https://hashcards.example.com/art/file/img.jpg
Absolute paths such as href="/" are also prefixed for sub-path routes.
Note:
Accept-Encoding: gzipis never forwarded to the backend, because compressed response bodies cannot be rewritten.
hashwrap returns an HTML error page showing the process's stdout / stderr output and a "check again" link. Clicking the link triggers a fresh startup attempt.
If the port does not open within startup_timeout_sec seconds, hashwrap returns HTTP 500.
hashwrap is built on top of hashcards by @eudoxia0.
Docker configuration is inspired by hashcards-docker by @Bastian.
MIT

