Deployment Guide¶
This section explains how to deploy the Taiwan Health MCP server to production. The project is container-first; Docker deployment is strongly recommended for environment consistency.
For the full variable reference see Configuration. This page is the ordered deployment procedure.
Supported environments¶
- Operating system: Linux (Ubuntu/CentOS), macOS, Windows (WSL2)
- Container platform: Docker Engine 24+ with Docker Compose v2 (
docker compose, not the legacydocker-compose) - Node.js: 20 or newer (for bare-metal deployment or local development). The project has no Python runtime dependency.
Resource requirements¶
| Item | Minimum | Recommended | Notes |
|---|---|---|---|
| CPU | 2 cores | 4+ cores | Imports and embedding jobs are the dominant load |
| Memory | 8 GB | 16 GB+ | admin-worker alone is configured with --max-old-space-size=8192 (required by FHIR IG imports) |
| Disk | 20 GB | 100 GB+ | PostgreSQL plus MinIO drug assets (inserts / labels / pill images) grow continuously |
The embedding service, OCR (MinerU), and the analysis LLM are all external HTTP services. They are not part of this compose stack — provision them separately and configure them in the admin console.
Services¶
docker compose up -d starts the following services:
| Service | Description |
|---|---|
nginx |
The front door (:8080 by default, set by WEB_PORT). Routes /mcp, /openapi.json, /tools/*, /admin/api/*, /admin/ws, /fhir-client/*, and /fhir-oauth/* to app, and everything else to web. |
web |
Next.js front-end: the /admin console SPA. |
app |
Node MCP server + admin REST API. Only exposes port 8000 on the compose network; never published to the host. |
admin-worker |
Background job runner: every import (including the three-stage drug pipeline) and embedding job. |
postgres |
PostgreSQL 16 + pgvector. |
pgbouncer |
Connection pool (transaction mode). |
redis |
Response cache. |
minio + minio-init |
Drug asset object storage and bucket initialisation. |
Data imports are triggered from the admin console and run inside admin-worker; there is no longer a standalone data-loader container.
Application traffic goes through nginx only
Never use http://<host>:8000 in documentation or client configuration — app does not publish that port to the host.
All application traffic (including MCP) must go through http://<host>:8080.
(postgres and minio do publish ports to the host for operational access; for production see Exposed ports and hardening.)
Deployment procedure¶
Step 1: Get the code¶
git clone https://github.com/healthymind-tech/Taiwan-Health-MCP.git
cd Taiwan-Health-MCP
Step 2: Create .env¶
cp .env.example .env
The defaults in .env.example must not go straight to production. The following must be handled:
2-1. Required / must-change variables¶
| Variable | Why it matters | Suggested approach |
|---|---|---|
POSTGRES_PASSWORD |
docker compose fails outright when unset (compose enforces it with :?); app refuses to start while the .env.example placeholder is still in place |
openssl rand -hex 24 |
ADMIN_ENABLED |
Defaults to true (/admin is the only way to import data). Set it to false once the data is loaded, to shrink a public deployment's attack surface |
As needed |
ADMIN_USERNAME |
Admin console account | Defaults to admin; customisable |
ADMIN_INITIAL_PASSWORDor ADMIN_PASSWORD_HASH |
Without a credential you cannot sign in | Pick one, see 2-2 |
ADMIN_SESSION_SECRET |
Session cookie signing key — holding it is enough to forge a session for any user, no password involved; also the fallback for FHIR_SERVER_SECRET_KEY. app refuses to start while the .env.example placeholder is still in place |
openssl rand -hex 32 |
MINIO_ACCESS_KEY / MINIO_SECRET_KEY |
Default to minioadmin / minioadmin, effectively no password |
openssl rand -hex 16 each |
PUBLIC_BASE_URL |
Public origin, used to build the OAuth2 redirect_uri and to derive the WebAuthn RP ID | https://your-domain.example.com |
PUBLIC_TOOLS_AUTH_MODE / PUBLIC_TOOLS_BEARER_TOKEN |
Defaults to none, meaning /mcp and /tools/* are completely unauthenticated |
Set bearer + openssl rand -hex 32 when publicly reachable |
WEB_PORT |
Public port | Defaults to 8080; adjust per environment |
Generate every random value at once:
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)"
echo "ADMIN_SESSION_SECRET=$(openssl rand -hex 32)"
echo "FHIR_SERVER_SECRET_KEY=$(openssl rand -hex 32)"
echo "MINIO_ACCESS_KEY=$(openssl rand -hex 16)"
echo "MINIO_SECRET_KEY=$(openssl rand -hex 16)"
echo "PUBLIC_TOOLS_BEARER_TOKEN=$(openssl rand -hex 32)"
2-2. Set the admin console password¶
Pick one of two approaches:
(a) Plaintext initial password (simpler) — used only on first boot, when the database has no credential row yet, and ignored forever afterwards:
ADMIN_INITIAL_PASSWORD=your-strong-password
(b) A pre-computed hash:
node -e "console.log('sha256\$' + require('crypto').createHash('sha256').update('change-me').digest('hex'))"
ADMIN_PASSWORD_HASH=sha256$$1a2b3c...
When both are set, ADMIN_INITIAL_PASSWORD wins. Password hashes support sha256$<hex> and pbkdf2_sha256$<iterations>$<salt>$<hex>.
Every $ in .env must be written as $$
Docker Compose interpolates variables in .env, so a single $ is read as a variable reference.
When the hash starts with a letter (for example sha256$abc…), $abc… is treated as an undefined
variable and the value is silently truncated to sha256 — login then fails forever with no
obvious error message.
Write every $ as $$ (Compose collapses it back to a single $). A pbkdf2_sha256$...$...$...
hash contains several $; double each one. The same applies when the password itself or
ADMIN_SESSION_SECRET contains a $.
2-3. Keep the encryption key consistent¶
FHIR_SERVER_SECRET_KEY is the pgcrypto symmetric key for external FHIR servers' OAuth tokens and client secrets, falling back to ADMIN_SESSION_SECRET when unset. compose.yaml passes it to both app and admin-worker, and the two must match — otherwise the worker fails to decrypt with Illegal argument to function (empty key) or Wrong key or corrupt data (mismatched key).
2-4. Know which settings are read only once¶
Seed-only settings (MinIO, the TFDA crawler, the FHIR package registry, worker tuning) are read from .env exactly once, on first boot, and written into admin.app_settings (seedIfEmpty() inserts key by key with ON CONFLICT DO NOTHING). Editing .env afterwards has no effect. How changeable they remain, however, differs:
| Settings group | How to change it after first boot |
|---|---|
| TFDA crawler, FHIR package registry | Edit directly in Admin → Settings (applied hot) |
| MinIO (Storage), Worker Tuning | Read-only in the console, labelled "Owned by the deployment (.env / compose)". But since the values were already seeded into the database and the code reads the database, editing .env has no effect either — the only way is a direct UPDATE admin.app_settings followed by a restart of the relevant service. |
| Model endpoints (embedding / OCR / analysis LLM) | Never read from .env; configurable only in Admin → Settings |
ADMIN_MAX_CONCURRENT_JOBS is the exception: it never enters admin.app_settings. The worker reads the environment variable on every start, so editing .env and restarting the worker takes effect.
Set the MinIO credentials before the very first boot
MINIO_ACCESS_KEY / MINIO_SECRET_KEY have two consumers that behave differently:
- The
miniocontainer reads.envon every start for its root credentials (MINIO_ROOT_USER/MINIO_ROOT_PASSWORD); app/admin-workeruse the copy seeded intoadmin.app_settingson first boot (seedIfEmpty()writes withON CONFLICT DO NOTHINGand never overwrites).
Changing .env after the first boot leaves the MinIO server's credentials out of sync with the stored application settings, breaking object storage reads and writes.
Worse, the Storage group in Settings is read-only (labelled "Owned by the deployment (.env / compose)"), so it cannot be fixed from the console. If this has already happened, the only route is the database:
sql
UPDATE admin.app_settings SET value = '<new-access-key>'
WHERE group_key = 'minio' AND key = 'access_key';
UPDATE admin.app_settings SET value = '<new-secret-key>'
WHERE group_key = 'minio' AND key = 'secret_key';
Then restart app and admin-worker.
See Configuration for details.
2-5. A minimal working .env¶
# --- Public ---
WEB_PORT=8080
PUBLIC_BASE_URL=https://taiwan-health-mcp.example.com
# --- Database ---
POSTGRES_DB=taiwan_health
POSTGRES_USER=mcp
POSTGRES_PASSWORD=<openssl rand -hex 24>
# --- MCP ---
MCP_PORT=8000
MCP_PATH=/mcp
PUBLIC_TOOLS_AUTH_MODE=bearer
PUBLIC_TOOLS_BEARER_TOKEN=<openssl rand -hex 32>
# --- Admin console ---
ADMIN_ENABLED=true
ADMIN_USERNAME=admin
ADMIN_INITIAL_PASSWORD=<strong password; write any $ as $$>
ADMIN_SESSION_SECRET=<openssl rand -hex 32>
ADMIN_COOKIE_SECURE=true # set explicitly when TLS terminates at a proxy
FHIR_SERVER_SECRET_KEY=<openssl rand -hex 32>
# --- Object storage (seed-only: read on first boot) ---
MINIO_ENDPOINT=minio:9000
MINIO_ACCESS_KEY=<openssl rand -hex 16>
MINIO_SECRET_KEY=<openssl rand -hex 16>
MINIO_BUCKET=taiwan-health-drug-assets
Step 3: Build and start¶
docker compose build # first build of the app / admin-worker / web images
docker compose up -d
On first boot the PostgreSQL container applies db/schema.sql automatically, and minio-init creates the bucket and exits (an exited status is normal).
Step 4: Verify¶
Check container status — postgres / redis / minio / pgbouncer should be healthy:
docker compose ps
Check the services and each module's status:
curl http://localhost:8080/openapi.json | head # currently registered tools
When startup fails, start with the logs:
docker compose logs -f app admin-worker
Step 5: Sign in and configure the model endpoints¶
Open http://<host>:8080/admin and sign in with the credentials from step 2.
Then configure the external model endpoints under Settings (these cannot be set from .env):
| Sub-page | What it configures | Consequence if unset |
|---|---|---|
| Embedding | Embedding endpoint / model (Ollama qwen3-embedding in the default scenario) |
Search falls back to keyword mode; Chinese keyword search finds almost nothing |
| Analysis LM | The LLM used to extract drug inserts | The drug analysis stage cannot run |
| OCR | MinerU service address | Insert PDFs cannot be OCR'd |
Model settings can be moved between environments with the JSON Export / Import under Settings → Backup & restore. See Admin Console.
Step 6: Load data¶
Import module by module from the Modules tab. Licensed source files (ICD-10, LOINC, SNOMED CT, RxNorm, FHIR IG) must be uploaded first; drugs, health supplements, and food nutrition are fetched automatically via API.
A manually queued drug_enrichment is not protected by the batch cap
DRUG_AUTOCHAIN_BATCH_LIMIT (default 200) applies only to auto-chained jobs.
A manually queued drug_enrichment with no limit crawls the entire pending queue,
issuing tens of thousands of requests against the TFDA site.
For the steps and scheduling see Getting Started and Jobs & Scheduling.
Day-2 operations¶
Updating and redeploying¶
When only application code changed, there is no need to restart the whole stack:
git pull
docker compose build app web admin-worker
docker compose up -d --no-deps app web admin-worker
After changing nginx/nginx.conf:
docker compose restart nginx
Containers only need recreating when a bootstrap variable in .env changed (DB / Redis / MCP / ADMIN_*):
docker compose up -d
(Changing the seed-only block has no effect; see 2-4.)
Database migrations¶
db/schema.sql is applied automatically on first boot. Existing environments must apply the incremental changes under db/migrations/ themselves, in filename date order:
docker compose exec -T postgres \
sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1' \
< db/migrations/20260802_llm_call_log.sql
To apply all of them in order (drop the ones already applied yourself — the project has no built-in migration version tracking):
for f in $(ls db/migrations/*.sql | sort); do
echo "applying $f"
docker compose exec -T postgres \
sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1' < "$f"
done
Credentials are expanded inside the container
The $POSTGRES_USER / $POSTGRES_DB above are expanded inside the container (which is why the
whole command is wrapped in a single-quoted sh -c), so nothing needs exporting or substituting on the host.
Connect to postgres directly, not pgbouncer
Run migrations through the postgres container. pgBouncer runs in transaction mode and is unsuitable for DDL batches.
Backups¶
Two approaches:
(a) Admin console (recommended) — Settings → Backup & restore lets you select settings and credentials, the PostgreSQL database, and MinIO object storage. It queues a system_backup background job; the resulting ZIP is written back to the system-backups/ prefix in MinIO and can be downloaded from the backup history.
(b) Manual:
# PostgreSQL (custom format, for pg_restore)
docker compose exec -T postgres \
sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > backup-$(date +%F).dump
# MinIO objects (volume level)
docker run --rm -v taiwan-health-mcp_minio_data:/data -v "$PWD":/backup \
alpine tar czf /backup/minio-$(date +%F).tar.gz -C /data .
The volume name is prefixed with the compose project name (the directory name by default); confirm the actual name with
docker volume ls.
Backups contain API keys, login credentials, and medical data — treat them as production secrets.
Exposed ports and hardening¶
Ports compose.yaml currently publishes to the host:
| Service | Port | Binding | Production recommendation |
|---|---|---|---|
nginx |
${WEB_PORT} → 80 |
All interfaces | Put a TLS reverse proxy in front |
postgres |
5432 |
All interfaces | Rebind to 127.0.0.1:5432:5432 or remove |
minio |
9000 |
All interfaces | Rebind to 127.0.0.1:9000:9000 or remove |
minio console |
9001 |
127.0.0.1 |
Leave as is |
redis |
6379 |
127.0.0.1 |
Leave as is |
app metrics |
${METRICS_PORT} |
127.0.0.1 |
Leave as is |
Pre-launch checklist:
-
POSTGRES_PASSWORD,ADMIN_SESSION_SECRET, andFHIR_SERVER_SECRET_KEYare all strong random values - MinIO credentials no longer use the
minioadmindefaults -
postgres:5432/minio:9000rebound to127.0.0.1or blocked by a firewall -
PUBLIC_TOOLS_AUTH_MODE=bearerwith a high-entropy token (if/mcpis publicly reachable) - TLS terminates at a front proxy and
ADMIN_COOKIE_SECURE=true -
.envpermissions tightened (chmod 600 .env) and kept out of version control
Override the port bindings with docker-compose.override.yml instead of editing compose.yaml:
services:
postgres:
ports:
- "127.0.0.1:5432:5432"
minio:
ports:
- "127.0.0.1:9000:9000"
The same override file is a good home for resource limits.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
docker compose up fails immediately with POSTGRES_PASSWORD is required |
The variable is unset in .env |
Set it (compose enforces it with :?) |
/admin returns 404 |
ADMIN_ENABLED was set to false |
Set it back to true and run docker compose up -d |
/admin returns 503 |
Enabled, but the auth variables are incomplete | Check the missing field in the startup log, fill them in and restart |
| The admin password is correct but login fails | $ in .env was not written as $$, truncating the hash |
See the warning box in 2-2 |
Changing the password in .env has no effect |
The credential was written to admin.admin_credentials on first boot, and seeding uses ON CONFLICT DO NOTHING |
Change it under Settings → Privacy instead |
The worker raises Illegal argument to function / Wrong key or corrupt data |
FHIR_SERVER_SECRET_KEY is empty, or differs between app and the worker |
See 2-3 |
Changing TFDA / registry settings in .env has no effect |
That group is seed-only, read only on first boot | Change it in Admin → Settings |
Changing MinIO / worker tuning in .env has no effect |
Seed-only, and read-only in the console | UPDATE admin.app_settings directly, then restart the service (see 2-4) |
| Chinese search returns almost nothing | No embedding endpoint configured, so search fell back to keyword mode | Configure the endpoint under Settings → Embedding |
| The worker OOMs during an IG import | NODE_OPTIONS was lowered, or the container memory limit is too small |
Keep --max-old-space-size=8192 and give the container a 10G limit |
http://<host>:8000 is unreachable |
By design — app publishes no host port |
Always use http://<host>:${WEB_PORT} |
Further reading¶
Architecture & Container Deployment¶
Infrastructure topology, container composition, and the startup sequence.
Configuration¶
The full parameter reference, covering bootstrap variables (.env) and seed-only settings (managed in Admin → Settings after first boot).
Performance & Monitoring¶
Optimisation advice for high-concurrency scenarios, connection pool and cache strategy, and Prometheus monitoring.
The public pages moved out of this repo
The public-facing marketing and legal pages (/, /status, /privacy, /dpa) are
now served by a standalone marketing-site project, not by the web service here.
/privacy is the URL registered with the Anthropic Connectors Directory, so once the
new site is live, add the 301 redirects in nginx/nginx.conf (a TODO block is in place).