QuanCard Server · Install guide
Deploy your own QuanCard sync service
QuanCard Server is one container image that serves both the API and the web vault. With this guide you can be online and paired with your iPhone in about 15 minutes. The commands match the deployment guide in the GitHub repository.
Requirements
- A Linux host with Docker Engine 24+ and Docker Compose v2. One vCPU and 512 MB of memory are enough for a household: Argon2id runs in the browser, not on the server.
- A domain whose DNS A/AAAA record points to the host, with ports 80 and 443 reachable (Let's Encrypt validation and the HTTP→HTTPS redirect).
- Nothing else bound to ports 80/443, or use your own reverse proxy.
HTTPS is not optional. Apart from the local development mode, the server answers plain HTTP with 426 Upgrade Required. The iPhone also connects only to HTTPS servers.
Quick install (recommended: bundled Caddy)
git clone https://github.com/zoolapp/quancard-server.git && cd quancard-server
./scripts/init-env.sh vault.example.com # writes .env with random secrets (never overwrites)
docker compose up -d --wait # Caddy obtains and renews certificatesOpen https://vault.example.com, enter the setup token printed by init-env.sh (also stored in .env), and create the owner account. The default compose.yaml is already hardened:
| Setting | Why |
|---|---|
| The app container publishes no ports and sits on an internal network | Only Caddy can reach it, so X-Forwarded-Proto cannot be spoofed |
Read-only file system, tmpfs /tmp, all capabilities dropped, no-new-privileges, uid 10001 | Limits the blast radius of any bug |
Named volume quancard-data | The SQLite database (ciphertext and verifiers only) |
| Caddy log filter | Drops query strings and headers, masks client IPs |
One-step installer
scripts/install.sh clones the pinned release into ~/quancard, writes the configuration and pins the image version. If the published image is not available, it builds the same version from source. Then it starts the stack. Read it before you run it. The script installs the latest release; for features still in development, such as the conflict centre, use the git clone method above.
curl -fsSL https://raw.githubusercontent.com/zoolapp/quancard-server/v0.1.1/scripts/install.sh -o install.sh
less install.sh
sh install.sh vault.example.comBehind your own reverse proxy
Already running nginx, Traefik or Cloudflare Tunnel? Run only the quancard service and publish it on loopback:
services:
quancard:
image: ghcr.io/zoolapp/quancard-server:latest
environment:
QC_SECRET: ${QC_SECRET}
QC_SETUP_TOKEN: ${QC_SETUP_TOKEN}
QC_PUBLIC_ORIGIN: https://vault.example.com
QC_TRUST_PROXY: "1"
ports: ["127.0.0.1:8080:8080"]
volumes: [quancard-data:/data]
read_only: true
tmpfs: ["/tmp:size=16m"]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
volumes: { quancard-data: {} }Your proxy must terminate TLS, forward Host unchanged, set X-Forwarded-Proto: https and X-Forwarded-For, and allow request bodies up to 17 MB. QC_TRUST_PROXY=1 trusts exactly one hop: never expose port 8080 directly when it is set. nginx example:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $remote_addr;
client_max_body_size 17m;
}Cloudflare Tunnel works the same way (service: http://localhost:8080). Cloudflare then terminates TLS and can see traffic metadata, but vault contents stay end-to-end encrypted.
Try it locally
./scripts/init-env.sh localhost
docker compose -f compose.local.yaml up -d
open http://localhost:8080Binds only to 127.0.0.1. Use Chrome, Edge or Firefox for the trial: the session cookie is always Secure, and Safari may refuse it over plain http://localhost. Do not keep real data in local mode on a shared machine.
Configuration
| Variable | Required | Meaning |
|---|---|---|
QC_DOMAIN | compose | Host name for Caddy and QC_PUBLIC_ORIGIN |
QC_SECRET | yes | ≥ 32 random bytes in hex. Encrypts TOTP secrets at rest and keys HMACs. Back it up: losing it disables everyone’s two-step verification |
QC_SETUP_TOKEN | first run | One-time owner setup token (≥ 24 characters). Without it, setup is closed |
QC_PUBLIC_ORIGIN | yes* | The https:// origin users browse to (*or QC_ALLOW_INSECURE_LOCALHOST=1 for local development) |
QC_TRUST_PROXY | behind a proxy | 1 trusts one proxy hop for X-Forwarded-* headers |
QC_ALLOW_INSECURE_LOCALHOST | development | 1 accepts plain HTTP for localhost / 127.0.0.1 |
QC_DATA_DIR · QC_PORT · QC_HOST · QC_WEB_ROOT | no | Defaults /data, 8080, 0.0.0.0, /app/web in the image |
QC_IMAGE_TAG | no | Image tag used by compose; pin it, then upgrade deliberately |
First run
- Create the owner account. Paste the setup token, choose a username and a passphrase of at least 15 characters. There is no password reset: the server never knows your password, so it cannot recover it.
- Create a vault. A new vault starts with one sample card. Load sample data fills in the sample catalogue: 15 cards in total, including the welcome card, and 6 accounts with public test numbers; Settings → Sample data removes them again. To continue an existing vault, import its
QC1.…recovery code. - The setup token is then spent. Later visitors see the sign-in page.
Pair your iPhone
- In the browser: Settings → Devices → Pair iPhone, and enter your password again. A one-time QR code appears; it expires after 10 minutes.
- On the iPhone: Settings → Sync → Self-hosted Sync → Scan pairing QR code (or paste the pairing link). Check the destination, then tap Connect and sync.
- Both screens play the same linked animation and show iPhone connected → Syncing → All set. From then on, changes on either side appear on the other.
The QR code contains your vault key. Show it only to your own phone, and close it when you are done; the server never sees the key. To re-pair, on the iPhone go to Manage Sync → Disconnect (your local items stay), revoke the old device in the browser, then scan again. Items that are unchanged on both sides are recognised as the same item and do not become conflicts.
Family and security
- Invite members: Settings → Members → Create invite link (single use, 7 days). Each member has a separate account and a separately encrypted vault; the owner cannot read it.
- Two-step verification: Settings → Security → Two-step verification. Scan the code with an authenticator app and store the recovery codes offline.
- Auto-lock: after 1, 5, 15 or 30 minutes idle; the vault also locks 60 seconds after the tab goes to the background.
- Sessions and activity: see every signed-in browser and sign out the others in one click; the activity log is append-only.
Conflicts
QuanCard never lets a device clock decide which version wins. If two devices changed the same item while offline, a conflict banner appears:
- Identical copies, for example after re-pairing, are folded automatically and are not conflicts.
- The conflict centre marks every differing field; secrets only show "differs". Choose per item, or in bulk. Every bulk action lists exactly what it will do first.
Backups, restore, upgrades
./scripts/backup.sh # online, consistent snapshot → backups/quancard-<UTC>.sqlite
./scripts/restore.sh backups/quancard-….sqlite # verifies, stops, swaps, restarts
docker compose pull && docker compose up -d --wait # upgrade (take a backup first)Backups hold only ciphertext and verifiers, but keep them private anyway, and store .env (QC_SECRET) with them. Schedule a nightly backup, for example 0 3 * * * cd /opt/quancard && ./scripts/backup.sh. Encrypt off-site copies again with a tool like restic or age, and rehearse a restore.
Migrations run automatically at start-up inside a transaction. The server refuses to start on a database written by a newer version, so it never corrupts it; to roll back, restore the matching backup. Health: GET /healthz returns {"ok":true}; GET /api/v1/status reports the version and capabilities.
Hardening checklist
- DNS points to the host,
https://has a valid certificate, andhttp://redirects. - Port 8080 is not reachable from the internet.
.envischmod 600, backed up, and not committed anywhere.- The owner uses a long passphrase and two-step verification.
- Backups are scheduled, and a restore has been tested.
- The host and Docker receive security updates; the image is upgraded regularly.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| No setup page, only sign-in | The owner already exists. Sign in, or ask the owner for an invite. |
| The setup token is rejected | Copy QC_SETUP_TOKEN from .env exactly. Each token works for one setup only. |
| The iPhone cannot connect | The server must be reachable over HTTPS with a valid certificate. Open https://your-domain/healthz in the phone's browser. |
| The browser says HTTPS is required | Open the exact QC_PUBLIC_ORIGIN. Behind a proxy, set QC_TRUST_PROXY=1. |
| The pairing code expired | Codes last 10 minutes. Close the dialog and create a new one. |
| Forgot the password | It cannot be reset. A paired iPhone still keeps its local copy. |
| Lost the authenticator | Sign in with a recovery code, then turn two-step verification off and on again. |
Reference
- Source on GitHub · Getting started · Deployment guide
- Threat model · Reporting vulnerabilities · Changelog
- Protocols: auth v1 · pairing v1 · sync v1 · OpenAPI
QuanCard Server is a development preview without an independent security audit. You are responsible for deployment and maintenance. Read the threat model before you store real data.