Production Setup
This guide walks through deploying Dharini on a single Linux server with Docker Compose. You can host the supporting services in two ways:
| Managed cloud services | Self-hosted | |
|---|---|---|
| Database | AWS RDS PostgreSQL (or another managed PostgreSQL 16 with PostGIS) | Bundled postgres container (PostgreSQL 16 + PostGIS 3.4) |
| File storage | AWS S3 | Bundled Garage container (S3-compatible) |
| Redis | Bundled redis container | Bundled redis container |
Both options use the same application containers. Choose per component; for example, you can use RDS with Garage. Steps that differ are marked Managed or Self-hosted.
For a step-by-step Windows 11 (WSL 2) walkthrough of the fully self-hosted option, see deploy-readme.md in the repository root.
Prerequisites
Section titled “Prerequisites”- A Linux server (Ubuntu 24.04 LTS recommended) with at least 2 vCPUs and 4 GB of RAM (for example, a t3.medium).
- A domain name pointing at the server
- Ports 80 and 443 open to the internet, and SSH restricted to your own IP
- Docker Engine with the Compose plugin
- A working SMTP email server or SMS provider (Twilio). Users sign in with a one-time password (OTP) sent by email or SMS, so nobody can log in without one of these.
- Managed option only: an AWS account with permission to create RDS instances, S3 buckets and IAM users
Step 1: Prepare the database
Section titled “Step 1: Prepare the database”Managed: AWS RDS
Section titled “Managed: AWS RDS”- In the RDS console, choose Create database and select PostgreSQL 16.x.
- Pick the Production template, a strong master password, and an instance class suited to your load (db.t3.micro is enough to try it out).
- Place the instance in the same VPC as your server. Set Public access to No.
- Enable storage encryption and automated backups (7 days or more).
- In the RDS security group, allow inbound TCP 5432 from the server’s security group.
- Connect once and create the extensions the application needs:
PGSSLMODE=require psql -h <rds-endpoint> -U postgres -d postgresCREATE EXTENSION IF NOT EXISTS postgis;CREATE EXTENSION IF NOT EXISTS "uuid-ossp";CREATE EXTENSION IF NOT EXISTS pg_trgm;The migrations create pg_trgm themselves, but not PostGIS or uuid-ossp, so those two must exist before the first start. See Database Setup for details.
In .env, set DB_HOST to the RDS endpoint (not postgres), DB_SSL=true, and the matching DB_USERNAME, DB_PASSWORD and DB_DATABASE. deploy.sh then does not start the bundled PostgreSQL container.
Self-hosted: bundled PostgreSQL
Section titled “Self-hosted: bundled PostgreSQL”Nothing to create. The postgres service uses the postgis/postgis:16-3.4-alpine image. It creates the database named in DB_DATABASE with the user and password from DB_USERNAME and DB_PASSWORD, and enables PostGIS in it. Data is kept in the postgres_data Docker volume.
Keep DB_HOST=postgres in .env: deploy.sh starts the bundled container only for that value (or postgres16-postgis). Any other DB_HOST is treated as an external database.
The bundled container does not accept SSL connections, so you must set DB_SSL=false in .env (see Step 5).
Port 5432 is published on 127.0.0.1 only, so it is not reachable from other machines. Use an SSH tunnel for remote access.
Step 2: Prepare file storage
Section titled “Step 2: Prepare file storage”The backend stores photos and sequencing files in one S3 bucket and reads the district map files from it. All files sit under the S3_FOLDER prefix.
Managed: AWS S3
Section titled “Managed: AWS S3”- Create a bucket (for example
dharini-prod-storage) in the same region as your server. Keep Block Public Access on and enable encryption. - Create an IAM user with read, write and list access to that bucket only, and generate an access key.
- Add the CORS rule described below.
Self-hosted: Garage
Section titled “Self-hosted: Garage”Garage runs from the separate garage compose profile, using garage.toml in the repository root.
- Set the two secrets in
garage.tomlbefore first use. Replace theREPLACE_WITH_RPC_SECRETandREPLACE_WITH_ADMIN_TOKENplaceholders with fresh values fromopenssl rand -hex 32.setup.shdoes this for you. Garage will not start whilerpc_secretis still the placeholder. - Start Garage and set it up once:
docker compose --profile garage up -d garage
NODE_ID=$(docker exec garage /garage node id | cut -d'@' -f1)docker exec garage /garage layout assign "$NODE_ID" --zone local-dc --capacity 100Gdocker exec garage /garage layout apply --version 1
docker exec garage /garage bucket create dharini-bucketdocker exec garage /garage key create dharini-keydocker exec garage /garage bucket allow --read --write --owner dharini-bucket --key dharini-key- Copy the key ID and secret printed by
key createintoS3_ACCESSKEYIDandS3_SECRETACCESSKEYin.env. - In
.env, useS3_ENDPOINT=http://garage:3900(the address the backend uses inside the Docker network),S3_REGION=garageor any value, andS3_BUCKET=dharini-bucket. - Browsers cannot reach
http://garage:3900. Expose Garage’s S3 port through your reverse proxy (for example athttps://dharini.example.org/s3) and setS3_PUBLIC_URLto that address. The backend rewrites the internal endpoint to this public address in the upload URLs it hands to browsers. See Nginx Configuration.
The repository also has a setup.sh helper that automates this for Ubuntu. Read it before running it: it also installs Docker and the AWS CLI, hard-codes the bucket name dharini-bucket and the folder uploads, and its secret-generation step only works if garage.toml still contains REPLACE_WITH_RPC_SECRET and REPLACE_WITH_ADMIN_TOKEN placeholders. If those are not present it skips the step and keeps whatever secrets are in the file, so replace them by hand as above.
Bucket CORS rule
Section titled “Bucket CORS rule”Sequencing files (up to 5 GB each) are uploaded straight from the user’s browser to the bucket with a presigned PUT URL. They do not pass through the backend. The bucket therefore needs a CORS rule that allows your web origin, or the browser blocks every upload.
Save this as cors.json, replacing the origin with your web app’s address (the same value you put in CORS_ORIGINS):
{ "CORSRules": [ { "AllowedOrigins": ["https://dharini.example.org"], "AllowedMethods": ["PUT", "GET", "HEAD"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3600 } ]}Apply it with the AWS CLI. For AWS S3:
aws s3api put-bucket-cors --bucket dharini-prod-storage --cors-configuration file://cors.jsonFor Garage, add --endpoint-url http://localhost:3900 and export the Garage key as AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
Step 3: Prepare the server
Section titled “Step 3: Prepare the server”Install Docker on the server (Ubuntu example):
sudo apt-get updatesudo apt-get install -y ca-certificates curl gnupg lsb-release git
sudo mkdir -p /etc/apt/keyringscurl -fsSL https://download.docker.com/linux/ubuntu/gpg | \ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu \ $(lsb_release -cs) stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get updatesudo apt-get install -y docker-ce docker-ce-cli containerd.io \ docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USERsudo systemctl enable --now dockerLog out and back in so the group change takes effect, then check:
docker --versiondocker compose versionStep 4: Download the deployment files
Section titled “Step 4: Download the deployment files”The server needs only docker-compose.yml, deploy.sh and your .env. The application images are pulled from dsihartpark on Docker Hub (see Step 6), so nothing is built and the source code is not needed. Keep the folder name dharini: Docker Compose uses it as the project name, and the database volume is named after it.
mkdir dharini && cd dharinicurl -fsSLO https://raw.githubusercontent.com/dsih-artpark/dharini/production/docker-compose.ymlcurl -fsSLO https://raw.githubusercontent.com/dsih-artpark/dharini/production/deploy.shcurl -fsSL https://raw.githubusercontent.com/dsih-artpark/dharini/production/.env.example -o .envchmod +x deploy.sh(.env starts as a copy of .env.example.)
Step 5: Configure environment variables
Section titled “Step 5: Configure environment variables”Edit the .env you downloaded in Step 4. Docker Compose and the backend container both read it from this folder.
The backend reads some variables with a strict check at startup. If a required one is missing, the container crashes or never starts listening on port 4000. This is the smallest set that starts:
# ApplicationNODE_ENV=productionPORT=4000
# DatabaseDB_HOST=postgres # bundled container; or your RDS endpointDB_PORT=5432DB_DATABASE=dharini_dbDB_USERNAME=postgresDB_PASSWORD=<strong-password>DB_SSL=false # false for the bundled container; true (or unset) for RDS
# Redis (bundled container)REDIS_HOST=redisREDIS_PORT=6379
# Object storage (see Step 2)S3_REGION=<region>S3_BUCKET=<bucket-name>S3_ACCESSKEYID=<access-key-id>S3_SECRETACCESSKEY=<secret-access-key>S3_ENDPOINT=<endpoint> # AWS: https://s3.<region>.amazonaws.com; Garage: http://garage:3900S3_PUBLIC_URL= # Garage only: public address of the /s3 proxyS3_FOLDER=uploads
# SecurityJWT_SECRET=<long-random-string> # for example: openssl rand -hex 32
# CORS: browser origins allowed to call the API (no path, no trailing slash, no "*")CORS_ORIGINS=https://dharini.example.org
# Visit scheduling queue (BullMQ)QUEUE=dharini-queueJOB_SCHEDULER=visit-schedulerJOB_SCHEDULE_REPEAT_PATTERN=0 0 * * *
# Links in invitation emails and SMS. Must end with a slash.FRONTEND_PLATFORM=https://dharini.example.org/
# Genomics pipeline: all three are read at startup and must be set,# even if you do not use sequencing (they may be left empty).PIPELINE_URL=PIPELINE_CALLBACK_URL=WEBHOOK_SECRET=
# Email (SMTP) and SMSEMAIL_FROM=noreply@dharini.example.orgSMTP_HOST=SMTP_PORT=587SMTP_USER=SMTP_PASS=SMS_PROVIDER=twilio # or "console" to print messages to the logTWILIO_ACCOUNT_SID=TWILIO_AUTH_TOKEN=TWILIO_FROM_NUMBER=
# Web frontend (read when the container starts; see note below)NEXT_PUBLIC_API_URL=https://dharini.example.org/apiNEXT_PUBLIC_APP_ENV=productionNotes:
CORS_ORIGINSis mandatory outside development. If it is empty, the backend logs an error and never starts listening. List every browser origin that calls the API, including the mobile web app if you serve it.FRONTEND_PLATFORMis joined to paths by plain concatenation, so the trailing slash matters.PIPELINE_CALLBACK_URLis the public URL where the sequencing pipeline posts results back, which ishttps://dharini.example.org/api/sequencing/pipeline/webhookwhen you proxy the API under/api/.WEBHOOK_SECRETis the shared secret used to sign those callbacks.- The
NEXT_PUBLIC_*values are read when thefrontendcontainer starts, so the published image works for any deployment. After changing one, recreate the container (see Updating);docker compose restartdoes not re-read.env.NEXT_PUBLIC_APK_DOWNLOAD_URLandNEXT_PUBLIC_MOBILE_WEB_URLare empty by default, which hides the Android app and Mobile web links on the landing page and login dialog. See Mobile App for where each link comes from. NEXT_PUBLIC_API_URLis the address browsers use to reach the backend. The backend itself has no/apiprefix; the/apihere comes from the reverse proxy in Nginx Configuration.
See Environment Variables for what every variable does and for the optional ones.
Step 6: Start the services
Section titled “Step 6: Start the services”./deploy.sh productionThe script pulls the images and runs docker compose --profile production up -d --wait. It starts every service in the profile (redis, backend, frontend), plus mobile-web once a Flutter web build has been put in the mobile-web folder. It adds postgres when DB_HOST in .env is postgres (or postgres16-postgis), the bundled database, and garage when S3_ENDPOINT points at it. With any other DB_HOST, such as an RDS endpoint, the bundled database does not start. It waits until the services are healthy, which can take a few minutes on the first start. When you run docker compose yourself with the bundled database, add --profile bundled-db.
The images are published by GitHub Actions when a pull request is merged into staging or production, as dharini-backend and dharini-frontend. They are tagged with the branch name and sha-<short commit> (linux/amd64 only). Compose picks the tag from IMAGE_TAG (default production) and the registry from IMAGE_REGISTRY (default dsihartpark on Docker Hub). A fork can publish its own images and point IMAGE_REGISTRY at them. To build from the checkout instead, run ./deploy.sh production --build.
pgadminis only in thedevelopmentandstagingprofiles. For the database, usepsqlover an SSH tunnel.mobile-webis not published. It serves a Flutter web build made for your deployment from themobile-webfolder; see Mobile App.
Check that the containers are up:
docker compose --profile production --profile bundled-db psWith the bundled Postgres, the backend starts after the database reports healthy. With an external database (RDS), it exits and Docker restarts it until the database is reachable, so a few restarts on the first start are normal.
What happens when the backend starts
Section titled “What happens when the backend starts”When NODE_ENV is anything other than development, the backend does the following, in this order:
- Runs pending database migrations. If a migration fails, the process exits. You do not run migrations by hand.
- Runs the seeder. It loads the country, state and district
regionsif that table is empty, creates or reconciles the pre-defined roles and permissions, diseases, surveillance types and sample types, and creates the first super-admin account ifSUPER_ADMIN_EMAILis set (see Step 9). It is safe to run on every start. - Starts listening on
PORT(default 4000). - Loads GeoLens map layers in the background. The layers are about 525,000 rows (wetlands and water bodies) loaded from about 77 MB of compressed files built into the image. No poultry farm or livestock market list is included: put your own files in the
geolens-datafolder, asDATA_SOURCES.mdin the repository explains. This runs once into empty tables. The API works while it runs; the GeoLens map layers stay empty until it finishes. Failures are logged and do not stop the server.
Follow the progress with:
docker logs backend -fLook for Migrations completed. and Database seeded successfully.
Step 7: Load reference data
Section titled “Step 7: Load reference data”The backend loads the regions table (country, states, districts) from regions.sql at startup, when the table is empty. You do not load it by hand.
Step 8: Set up Nginx, SSL and DNS
Section titled “Step 8: Set up Nginx, SSL and DNS”Install Nginx and Certbot:
sudo apt-get install -y nginx certbot python3-certbot-nginxCreate the Nginx configuration as described in Nginx Configuration, then obtain a certificate:
sudo certbot --nginx -d dharini.example.orgAdd a DNS A record for your domain pointing at the server’s public IP address (TTL 300 is a good starting value).
Step 9: Create the first admin
Section titled “Step 9: Create the first admin”There are two ways to get a first administrator. Both sign in with an OTP, so working email or SMS delivery is required.
Self-serve signup. Anyone can open the web app and sign up. Signup verifies the person’s email or phone with an OTP, then creates a new workspace and makes that person the workspace administrator (workspace-admin). This is the normal route for a new deployment. It needs a configured email provider (RESEND_API_KEY or SMTP_*) or SMS provider (TWILIO_*).
First super admin. Set SUPER_ADMIN_EMAIL in .env before the first start, and optionally SUPER_ADMIN_FIRST_NAME, SUPER_ADMIN_LAST_NAME and SUPER_ADMIN_PHONE (see Environment Variables). The seeder then creates a super-admin account with those details, and you sign in as that user with an OTP sent to that address or number. It only creates the account if no user with that email exists, so changing the variables later does not rename an existing account. If SUPER_ADMIN_EMAIL is empty, no super admin is created.
You can then invite other users from the web app.
Step 10: Verify the deployment
Section titled “Step 10: Verify the deployment”-
Check the backend health endpoint:
Terminal window curl http://localhost:4000/healthIt returns
{"status":"ok", ...}. Through Nginx the address ishttps://dharini.example.org/api/health. The backend container has a Docker healthcheck on this endpoint (with a 300-second start period), sodocker compose psshowshealthyonce it answers. -
Open
https://dharini.example.org. The login page should load. -
Open
https://dharini.example.org/api/docs. The API documentation should load. -
Sign in and confirm you receive the OTP.
-
Check the logs for errors:
Terminal window docker logs backend --tail 100docker logs frontend --tail 100
Updating
Section titled “Updating”curl -fsSLO https://raw.githubusercontent.com/dsih-artpark/dharini/production/docker-compose.ymlcurl -fsSLO https://raw.githubusercontent.com/dsih-artpark/dharini/production/deploy.sh && chmod +x deploy.sh./deploy.sh productionThe two downloads refresh docker-compose.yml and deploy.sh; the script then pulls the newest production images and recreates the containers that changed. Volumes are kept. The backend applies new migrations and re-runs the seeder on startup. Take a database backup before updating (see Database Setup).
To deploy from GitHub Actions instead of by hand, see CI/CD with GitHub Actions.
Changing .env needs no new image. Re-run ./deploy.sh production (or docker compose --profile production up -d) to apply it. This includes NEXT_PUBLIC_* values.
Rolling back
Section titled “Rolling back”Every published build also has a sha-<short commit> tag. Run an earlier one with:
IMAGE_TAG=sha-abc1234 ./deploy.sh productionMigrations are not undone by this. Restore a database backup if the newer version changed the schema.
Post-Deployment
Section titled “Post-Deployment”- Set up monitoring and log collection
- Configure automated database backups
- Put the backend (4000) and frontend (3000) ports behind Nginx and a firewall. They are published on all interfaces. Postgres, Redis, pgAdmin and Garage listen on
127.0.0.1only - Test the main flows: login, creating a project, a visit, and a sample
Next Steps
Section titled “Next Steps”- Environment Variables - Full configuration reference
- Nginx Configuration - Detailed reverse proxy setup
- Database Setup - Migrations, backups and tuning
- Troubleshooting - Common issues and fixes