Applications Azure

Community Health Toolkit on Ubuntu 24.04 on Azure User Guide

| Product: Community Health Toolkit on Ubuntu 24.04 LTS on Azure

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, and 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 cloudimg 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: 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 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, so 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. This image is built and supported by cloudimg and 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.

The CHT admin app Users page, signed in as the per VM administrator, listing the community health worker chw_amina with the chw role at Riverside Health Centre

What is included:

  • 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 cloudimg support

What this image is, and is not. It is a working CHT server 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

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, so choose a 4 vCPU size and a larger OS disk (for example --os-disk-size-gb 128) for a programme with many health workers. 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

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

Step 2 - Deploy from the Azure CLI

az vm image terms accept --urn cloudimg1647283583153:community-health-toolkit:default:latest

az vm create \
  --resource-group my-cht-rg \
  --name cht-server \
  --image cloudimg1647283583153: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

ssh azureuser@<vm-ip>

The first boot takes about a minute: it creates the per VM secrets, starts the six containers and checks that the new administrator can sign in before it marks itself done.

Step 4 - Confirm the stack is running

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. 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.

systemctl reporting docker and community-health-toolkit active, the six CHT containers up, and the port check confirming only 22, 80 and 443 are listening

Step 5 - Retrieve your administrator credentials

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) - cloudimg 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 - Secure by default

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:

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/

What the image does on every VM:

  • 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-cloudimg.yml.
  • A per VM TLS certificate. The self signed certificate is created on first boot with the VM's IP address as its name. Compare its fingerprint with CHT_TLS_SHA256_FINGERPRINT the first time your browser warns you, 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.

The credentials file readable only by root with the password masked, API clients receiving 401 from CouchDB and admin endpoints, browsers redirected to the login page and HTTP redirected to HTTPS

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.

The CHT login page, the only thing reachable before signing in

As an administrator you land in the admin app at https://<vm-ip>/admin/, where you manage users, roles, forms, translations, images and settings. The web app, the one 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, so 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 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"]

The monitoring endpoint reporting CHT 5.3.1 on CouchDB 3.5.2 with an empty Sentinel backlog, the roles and place types from the admin API, the CouchDB databases and the per VM certificate

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. This creates a district and a health centre, then a health worker user with the chw role who looks after that health centre, and checks 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 (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 do the same 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.

The admin app Users page listing chw_amina

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

The CHT web app People tab with Riverside District opened, showing Riverside Health Centre inside it

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:

The CHT app signed in as the community health worker chw_amina, showing Riverside Health Centre

The users API listing chw_amina with the chw role at Riverside Health Centre, the people and places in the database, the Sentinel log and the CouchDB hardening check

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, kept in a project folder and deployed with cht-conf. Run it on your own workstation (it needs Node.js, and Python with pyxform if you convert XLSForms), not on the server:

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/, and running cht with no action compiles and uploads everything in the project (app settings, forms, resources, 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) rather than 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 the 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 use 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. Renew it before it expires by repeating the two docker cp commands and restarting 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). Stop the stack briefly for a consistent copy:

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, and 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, remove the containers and the CouchDB credentials volume (it is rebuilt from the restored .env), 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, and CouchDB keeps a hash of it in the cht_cht-credentials volume. To replace it, write a new password 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, 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, which 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 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, keeping 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 - Logs and troubleshooting

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: 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: 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, and 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

cloudimg provides 24/7 support for this image at support@cloudimg.co.uk. For questions about building CHT apps, the Community Health Toolkit community forum at forum.communityhealthtoolkit.org and the documentation at docs.communityhealthtoolkit.org are the best places to start.