Deployment guide

Community Health Toolkit

Overview

The Community Health Toolkit (CHT) is the open-source framework that health programmes use to build digital tools for community health workers. Health workers use the CHT web app or the CHT Android app to register households, complete assessment and follow-up forms, and work through prioritised tasks, even while offline. Everything synchronises to the server when they reconnect. Supervisors and administrators manage places, users and settings in the admin app, and app builders deploy their own forms, tasks, targets and contact summaries with the cht-conf command-line tool.

This SecureAnyCloud image runs the CHT Core Framework 5.3.1 server on Ubuntu 24.04 LTS the way Medic, the project's technical steward, publishes it for Docker. It uses Medic's own Docker Compose files for the release and Medic's own container images for CouchDB, the CHT API, Sentinel, haproxy, nginx and CouchDB search. Each image is pinned by digest and already stored in the image, so the VM starts without downloading anything.

On the first boot of every VM, a unique administrator password, CouchDB session secret, Erlang cookie and TLS certificate are generated before the service starts. No two VMs share a secret, and nothing is reachable without signing in.

Community Health Toolkit and CHT are names of the open-source project stewarded by Medic. SecureAnyCloud builds and supports this image. It is not affiliated with, sponsored by or endorsed by Medic or the Community Health Toolkit project. CHT Core is free software under the GNU Affero General Public License v3 and is shipped unmodified.

What you get:

  • CHT Core Framework 5.3.1 (released 15 September 2026), the current supported release line, with Medic's default demo configuration
  • Six containers from Medic's 5.3.1 compose files: nginx (the HTTPS front door), api, sentinel, haproxy, couchdb (Apache CouchDB 3.5.2, single node) and nouveau (CouchDB search)
  • Docker Engine with every image pinned by tag and sha256 digest, run by one systemd unit, community-health-toolkit.service
  • A per-VM administrator (medic), CouchDB session secret and UUID, Erlang distribution cookie and self-signed TLS certificate, all created on first boot
  • Only ports 22 (SSH), 443 (HTTPS) and 80 (which only redirects to HTTPS) open. CouchDB and the internal services are reachable only inside the VM's private Docker network.
  • 24/7 SecureAnyCloud support

What this image is, and is not. It is a working CHT server that you can sign in to, create places and users in, and configure for your programme. It ships Medic's default demo configuration, not a programme-specific app. You deploy your own forms, tasks and targets with cht-conf (Step 10).

It does not include a custom build of the CHT Android app, an SMS gateway, CHT Sync or analytics, or a multi-node CouchDB cluster. Medic's optional upgrade helper container is not included, so upgrades are a short command-line procedure (Step 14) rather than a button in the admin app.

Prerequisites

You need an Azure subscription, an SSH key pair and a virtual network in your chosen region.

  • Size: Standard_B2ms (2 vCPU, 8 GiB) or larger. With the stack running and idle, this image uses about 1.8 GiB of memory, measured on a 4 GiB Standard_B2s, so a B2s is enough for a quick evaluation. Medic's guidance for production Docker hosting is 4 vCPU, 8 GiB of memory and a 100 GB SSD. For a programme with many health workers, choose a 4 vCPU size and a larger OS disk (for example --os-disk-size-gb 128). The file system grows to fill the disk automatically on first boot.
  • Network security group inbound rules: 22/tcp from your management network, 443/tcp from where your users connect, and optionally 80/tcp (it only redirects to HTTPS).
  • A DNS name for the VM, if you want a trusted certificate for your users and the CHT Android app (Step 11).

Step 1 - Deploy from the Azure Marketplace

  • In the Azure portal, choose Create a resource and search the Marketplace for Community Health Toolkit by SecureAnyCloud.
  • Select the plan and choose Create.
  • On Basics, pick the subscription, resource group and region, name the VM and choose Standard_B2ms or larger.
  • Choose SSH public key authentication with the username azureuser and paste your public key.
  • On Networking, allow inbound 22/tcp from your management network and 443/tcp, and 80/tcp if you want the redirect.
  • On Disks, increase the OS disk size if this will be a production server.
  • Select Review + create, then Create, and note the VM's public IP address.

Step 2 - Deploy from the Azure CLI

Accept the Marketplace terms, create the VM, then open HTTPS and the HTTP redirect. SSH is opened by default.

az vm image terms accept --urn sac1647283583153:community-health-toolkit:default:latest
az vm create \
  --resource-group my-cht-rg \
  --name cht-server \
  --image sac1647283583153:community-health-toolkit:default:latest \
  --size Standard_B2ms \
  --os-disk-size-gb 128 \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard
# open HTTPS and the HTTP redirect (SSH is opened by default)
az vm open-port --resource-group my-cht-rg --name cht-server --port 443 --priority 900
az vm open-port --resource-group my-cht-rg --name cht-server --port 80 --priority 910

Step 3 - Connect to the VM

Connect over SSH as the administrator user you created in Step 1:

ssh azureuser@<vm-ip>

First boot takes about a minute. It creates the per-VM secrets and starts the six containers. It then checks that the new administrator can sign in before it marks itself done.

Step 4 - Confirm the stack is running

Check that Docker and the toolkit service are active and that all six containers are up:

systemctl is-active docker community-health-toolkit
sudo cht-compose ps --format 'table {{.Service}}\t{{.Status}}'

Expected output:

active active SERVICE STATUS api Up 4 minutes couchdb Up 4 minutes haproxy Up 4 minutes nginx Up 4 minutes nouveau Up 4 minutes sentinel Up 4 minutes

cht-compose is a small wrapper for docker compose that already knows the project name, the settings file and the three compose files in /opt/cht/compose.

Next, check which ports are open to the network:

sudo /usr/local/sbin/community-health-toolkit-port-check.sh
sudo ss -H -lnt | awk '{print $4}' | grep -vE '^(127\.|\[::1\])' | sort -u
PORTS_OK tcp=[22 80 443] udp=[68] 0.0.0.0:22 0.0.0.0:443 0.0.0.0:80 [::]:22 [::]:443 [::]:80

Only SSH, HTTPS and the HTTP redirect listen on the network. CouchDB (5984), the CHT API (5988), haproxy and the Erlang ports (4369, 9100) exist only on the private Docker network inside the VM.

Step 5 - Retrieve your administrator credentials

List and read the root-only credentials file. First boot generated these values for this VM only:

sudo ls -l /root/community-health-toolkit-credentials.txt
sudo cat /root/community-health-toolkit-credentials.txt
-rw------- 1 root root 882 Sep 17 06:42 /root/community-health-toolkit-credentials.txt
# Community Health Toolkit (CHT Core 5.3.1) - sac Azure Marketplace image
# These values were generated for THIS virtual machine on its first boot.
# Keep this file secret (mode 0600, root only).
# CHT_URL=https://203.0.113.10/ CHT_ADMIN_USER=medic CHT_ADMIN_PASSWORD=<your per VM password> CHT_TLS_SHA256_FINGERPRINT=35:2D:10:FE:13:AE:65:87:24:53:E6:4C:79:77:0C:DF:5F:E7:39:51:BE:5B:0F:83:F1:C3:95:33:8B:48:A3:02

The medic account is both the CHT administrator and the CouchDB administrator, so it is also the account cht-conf uses. The password is unique to this VM. Step 13 shows how to replace it with a new one.

Step 6 - Verify the secure defaults

Nothing in CHT or CouchDB is reachable without signing in. API clients get 401, browsers are sent to the login page, and plain HTTP only redirects to HTTPS. Confirm this:

for path in /_all_dbs /_utils/ /_node/_local/_config /api/v1/users; do printf '%-24s %s\n' "$path" "$(curl -sk -o /dev/null -w '%{http_code}' -H 'Accept: application/json' "https://localhost$path")" done curl -sk -o /dev/null -w 'browser request to /_utils/: %{http_code} -> %{redirect_url}\n' https://localhost/_utils/ curl -s -o /dev/null -w 'plain HTTP: %{http_code} -> %{redirect_url}\n' http://localhost/
/_all_dbs 401 /_utils/ 401 /_node/_local/_config 401 /api/v1/users 401 browser request to /_utils/: 302 -> https://localhost/medic/login?redirect=%2F_utils%2F plain HTTP: 301 -> https://localhost/

On every VM, the image provides the following:

  • No shared or default credentials. The administrator password, the CouchDB session secret and UUID, and the Erlang distribution cookie are generated on first boot. The upstream CouchDB container has the same Erlang cookie on every installation, so this image replaces it with a per-VM cookie through a small override file, /opt/cht/compose/cht-sac.yml.
  • A per-VM TLS certificate. First boot creates a self-signed certificate with the VM's IP address as its name. The first time your browser warns you, compare the certificate's fingerprint with CHT_TLS_SHA256_FINGERPRINT. Then install a real certificate (Step 11).
  • Brute-force protection. The CHT API answers 429 Too Many Requests after 10 failed sign-in attempts within 10 seconds from the same address or for the same user name.
  • No data. The CouchDB databases are created fresh on first boot and contain only Medic's default demo configuration.

Step 7 - Sign in to the CHT web app

Browse to https://<vm-ip>/. Your browser warns that the certificate is self-signed. Check that the SHA-256 fingerprint it shows matches CHT_TLS_SHA256_FINGERPRINT, then continue. Sign in with the user medic and the password from Step 5.

As an administrator, you land in the admin app at https://<vm-ip>/admin/. There you manage users, roles, forms, translations, images and settings. The web app, which health workers use, is at https://<vm-ip>/.

Administrators work online, so the self-signed certificate is fine for them. Health workers are offline users. The CHT app installs itself in their browser so it keeps working without a connection, and browsers only allow that on a site with a trusted certificate. Over the self-signed certificate, a health worker sees Loading error, please check your connection. Install a trusted certificate (Step 11) before health workers sign in.

Step 8 - Check the stack health from the command line

The monitoring endpoint reports the CHT, Node.js and CouchDB versions, the Sentinel backlog and database sizes. It is public by design, so that monitoring tools such as CHT Watchdog can read it. It shows counts and versions, never records or credentials.

curl -sk https://localhost/api/v2/monitoring | jq '{version, sentinel_backlog: .sentinel.backlog, medic_doc_count: .couchdb.medic.doc_count}'
{ "version": { "app": "5.3.1", "node": "v22.23.2", "couchdb": "3.5.2" }, "sentinel_backlog": 0, "medic_doc_count": 46 }

Authenticated requests use the medic account. This command reads the roles and place types in the default configuration and lists the CouchDB databases:

CHT_PASS="$(sudo awk -F= '$1=="CHT_ADMIN_PASSWORD"{print $2}' /root/community-health-toolkit-credentials.txt)" curl -sk -u "medic:${CHT_PASS}" https://localhost/api/v1/settings | jq -c '{roles: (.roles | keys), place_types: [.contact_types[].id]}' curl -sk -u "medic:${CHT_PASS}" https://localhost/_all_dbs | jq -c .
{"roles":["analytics","chw","chw_supervisor","crfo","data_entry","gateway","national_admin","program_officer"],"place_types":["district_hospital","health_center","clinic","person"]} ["_global_changes","_replicator","_users","medic","medic-audit","medic-logs","medic-purged-cache","medic-sentinel","medic-users-meta","medic-vault"]

Step 9 - Create a health facility and a community health worker

Health workers belong to a place in your hierarchy. In the default configuration, a district_hospital contains health_center places, which contain clinic places and people.

Run the following to create a district and a health centre. It then creates a health worker user with the chw role who looks after that health centre, and checks that the new user can sign in:

CHT_PASS="$(sudo awk -F= '$1=="CHT_ADMIN_PASSWORD"{print $2}' /root/community-health-toolkit-credentials.txt)" PLACE_ID="$(curl -sk -u "medic:${CHT_PASS}" -X POST https://localhost/api/v1/places \
  -H 'Content-Type: application/json' \
  -d '{"name": "Riverside Health Centre", "type": "health_center", "parent": {"name": "Riverside District", "type": "district_hospital"}}' \
  | jq -r .id)" echo "health centre id: ${PLACE_ID}" CHW_PASS="$(openssl rand -base64 18)" jq -n --arg password "${CHW_PASS}" --arg place "${PLACE_ID}" \
  '{username: "chw_amina", password: $password, roles: ["chw"], place: $place, contact: {name: "Amina Otieno"}}' \
  | curl -sk -u "medic:${CHT_PASS}" -X POST https://localhost/api/v1/users \
  -H 'Content-Type: application/json' --data-binary @- | jq -c '{user: .user.id, contact: .contact.id}' jq -n --arg password "${CHW_PASS}" '{user: "chw_amina", password: $password}' \
  | curl -sk -o /dev/null -w 'chw_amina sign in: HTTP %{http_code}\n' -X POST https://localhost/medic/login \
  -H 'Content-Type: application/json' --data-binary @- echo "Give chw_amina this password: ${CHW_PASS}"
health centre id: 65d3563c7d08ff0a7ff1e812af0020f8 {"user":"org.couchdb.user:chw_amina","contact":"65d3563c7d08ff0a7ff1e812af00219f"} chw_amina sign in: HTTP 302 Give chw_amina this password: <a random password>

HTTP 302 is a successful sign-in, because the login page redirects into the app. The first time chw_amina signs in to the app, CHT asks her to replace the password you gave her with one of her own.

You can also manage users in the admin app under Users, and create many users at once from a CSV file with the cht-conf create-users action. List the users and their places:

CHT_PASS="$(sudo awk -F= '$1=="CHT_ADMIN_PASSWORD"{print $2}' /root/community-health-toolkit-credentials.txt)" curl -sk -u "medic:${CHT_PASS}" https://localhost/api/v2/users | jq -r '.[] | "\(.username) roles=\(.roles | join(",")) place=\(.place[0].name)"'
chw_amina roles=chw place=Riverside Health Centre medic roles=admin,mm-online place=null

The medic administrator appears in the list once it has signed in to the web app.

In the web app, the People tab shows the hierarchy you just created.

When chw_amina signs in over a trusted certificate, the app downloads only the people and places she is responsible for, so she can keep working offline.

Step 10 - Deploy your own configuration with cht-conf

Real programmes replace the demo configuration with their own forms, tasks, targets, contact summaries and translations. Keep them in a project folder and deploy them with cht-conf.

Run it on your own workstation, not on the server. It needs Node.js, and Python with pyxform if you convert XLSForms:

npm install -g cht-conf cht initialise-project-layout cht --url=https://medic:<password>@<vm-ip> --accept-self-signed-certs backup-app-settings cht --url=https://medic:<password>@<vm-ip> --accept-self-signed-certs

initialise-project-layout creates a project skeleton. backup-app-settings saves the server's current settings into backups/. Running cht with no action compiles and uploads everything in the project: app settings, forms, resources and translations. This was tested against this image with cht-conf 6.6.1.

Remove --accept-self-signed-certs once the server has a trusted certificate.

Start from one of Medic's reference configurations in the config folder of the cht-core repository (for example config/default), not from an empty project. An empty project replaces the demo settings with an almost empty configuration.

Step 11 - Install a trusted TLS certificate

Health workers' browsers and the CHT Android app need a certificate they trust. Over a self-signed certificate, the offline app cannot load.

Point a DNS name at the VM. Obtain a certificate and private key for it from your certificate authority, for example Let's Encrypt with DNS validation. Copy the full chain and key to the VM, then install them and switch the front door to your own certificate:

sudo docker cp fullchain.pem cht-nginx-1:/etc/nginx/private/cert.pem
sudo docker cp privkey.pem cht-nginx-1:/etc/nginx/private/key.pem
sudo sed -i 's/^CERTIFICATE_MODE=.*/CERTIFICATE_MODE=OWN_CERT/; s/^COMMON_NAME=.*/COMMON_NAME=<your-domain>/' /opt/cht/.env
sudo cht-compose up -d nginx echo | openssl s_client -connect localhost:443 -servername <your-domain> 2>/dev/null | openssl x509 -noout -subject -issuer -enddate

The certificate lives in the cht_cht-ssl Docker volume, so it survives restarts and upgrades. To renew it before it expires, repeat the two docker cp commands and restart the front door with sudo cht-compose restart nginx.

Step 12 - Back up the server

Everything that matters is in /opt/cht: the CouchDB data (couchdb), the settings file (.env) and the Erlang cookie (secrets). To take a consistent copy, stop the stack briefly:

sudo systemctl stop community-health-toolkit
sudo install -d -m 0700 /var/backups/cht
sudo tar -C /opt/cht -czf "/var/backups/cht/cht-$(date +%Y%m%d-%H%M).tar.gz" couchdb .env secrets
sudo systemctl start community-health-toolkit for i in $(seq 1 60); do curl -sk https://localhost/api/v2/monitoring | jq -e '.version.app' >/dev/null 2>&1 && break sleep 5 done
sudo ls -lh /var/backups/cht/ curl -sk https://localhost/api/v2/monitoring | jq -c '{app: .version.app, sentinel_backlog: .sentinel.backlog}'
total 1.4M -rw-r--r-- 1 root root 1.4M Sep 17 06:47 cht-20260917-0647.tar.gz {"app":"5.3.1","sentinel_backlog":0}

Copy the archive off the VM, for example to Azure Blob Storage. Also consider Azure Backup or scheduled disk snapshots. The archive contains the administrator password in .env, so store it as a secret. If you installed your own certificate, keep a copy of it too.

To restore onto this VM or a new VM from this image, stop the stack and remove the containers. Remove the CouchDB credentials volume as well, because it is rebuilt from the restored .env. Then replace the data and start again. The administrator password becomes the one in the backup:

BACKUP=<backup-dir>/cht-20260917-0647.tar.gz
sudo systemctl stop community-health-toolkit
sudo cht-compose down
sudo docker volume rm cht_cht-credentials
sudo find /opt/cht/couchdb -mindepth 1 -delete sudo tar -C /opt/cht -xzf "${BACKUP}"
sudo systemctl start community-health-toolkit

This procedure was tested on this image. After restoring onto a freshly started VM, the backup's administrator password signed in, the new VM's own password was refused and the restored records were present.

Step 13 - Change the administrator password

The medic password lives in /opt/cht/.env, which every container reads. CouchDB keeps a hash of it in the cht_cht-credentials volume.

To replace the password, write a new one to both files and let CouchDB rebuild its credentials from .env:

NEW_PASS="$(openssl rand -base64 36 | tr -dc 'A-Za-z0-9' | cut -c1-32)" sudo systemctl stop community-health-toolkit
sudo cht-compose down
sudo docker volume rm cht_cht-credentials
sudo sed -i "s/^COUCHDB_PASSWORD=.*/COUCHDB_PASSWORD=${NEW_PASS}/" /opt/cht/.env
sudo sed -i "s/^CHT_ADMIN_PASSWORD=.*/CHT_ADMIN_PASSWORD=${NEW_PASS}/" /root/community-health-toolkit-credentials.txt
sudo systemctl start community-health-toolkit for i in $(seq 1 60); do curl -sk https://localhost/api/v2/monitoring | jq -e '.version.app' >/dev/null 2>&1 && break sleep 5 done sleep 15 curl -sk -o /dev/null -w 'new password: HTTP %{http_code}\n' -H 'Accept: application/json' -u "medic:${NEW_PASS}" https://localhost/_all_dbs
new password: HTTP 200

Health workers' accounts and data are not affected. Update any cht-conf commands or monitoring that use the old password.

Step 14 - Upgrade to a newer CHT release

Medic publishes the compose files for each release. Check the release notes at docs.communityhealthtoolkit.org/releases/ first and take a backup (Step 12). Then swap in the new release's files and restart. The API migrates the database automatically.

This procedure was rehearsed on this image from 5.3.0 to 5.3.1. It took about 30 seconds, with all data kept.

NEW=<version> cd /opt/cht/compose
sudo cp cht-core.yml cht-core.yml.bak && sudo cp cht-couchdb.yml cht-couchdb.yml.bak
sudo curl -fsSL -o cht-core.yml "https://staging.dev.medicmobile.org/_couch/builds_4/medic:medic:${NEW}/docker-compose/cht-core.yml"
sudo curl -fsSL -o cht-couchdb.yml "https://staging.dev.medicmobile.org/_couch/builds_4/medic:medic:${NEW}/docker-compose/cht-couchdb.yml"
sudo cht-compose pull
sudo systemctl restart community-health-toolkit

The image keeps CouchDB on your per-VM Erlang cookie by mounting its own copy of CouchDB's vm.args. After upgrading, confirm that the new CouchDB image has not changed that file apart from the cookie. No difference is printed when they match:

sudo docker run --rm --entrypoint cat public.ecr.aws/medic/cht-couchdb:5.3.1@sha256:c805988a3f2292ea1082264367c28a21120b2454d4fdaa4ba6ea45ab4b20402e /opt/couchdb/etc/vm.args \
  | diff - <(sudo sed "s/^-setcookie .*/-setcookie 'cht-couchdb-cluster'/" /opt/cht/secrets/couchdb-vm.args) \
  && echo "vm.args matches the image apart from the per-VM cookie"
vm.args matches the image apart from the per-VM cookie

After an upgrade, replace the image reference in that command with the new CouchDB image, for example public.ecr.aws/medic/cht-couchdb:<version>. If it prints a difference, copy the new lines into /opt/cht/secrets/couchdb-vm.args, keep your -setcookie line and restart the stack.

Ubuntu security updates install automatically. Docker Engine updates are applied when you run sudo apt-get update && sudo apt-get upgrade.

Step 15 - Read logs and troubleshoot

View the recent API log and the first-boot journal:

sudo cht-compose logs --no-log-prefix --tail 5 api
sudo journalctl -u community-health-toolkit-firstboot --no-pager | tail -5
  • The browser shows a certificate warning: this is expected until you install your own certificate (Step 11). Compare the fingerprint with the one in the credentials file.
  • A health worker sees "Loading error, please check your connection": the offline app cannot install itself over a self-signed certificate. Install a trusted certificate (Step 11).
  • 429 Too Many Requests when signing in: there were too many failed attempts in a short time. Wait ten seconds and try again with the right password.
  • The app is slow or containers restart: check memory with free -m and sudo docker stats --no-stream, then move to a larger VM size.
  • A container is not running: sudo cht-compose ps shows its state and sudo cht-compose logs <service> shows why. sudo systemctl restart community-health-toolkit restarts the whole stack.

Support

SecureAnyCloud provides 24/7 support for this image at sales@secureanycloud.com. For questions about building CHT apps, start with the Community Health Toolkit community forum at forum.communityhealthtoolkit.org and the documentation at docs.communityhealthtoolkit.org.

Deploy on Azure

Launch Community Health Toolkit on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.

View this image

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure