Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8f42683
feat: initial WireGuard
tonsV2 May 11, 2026
3013b3f
Merge branch 'development-2.0' into feat/vpn
tonsV2 May 11, 2026
3241bd3
feat: move Grafana to VPN-only access via grafana.internal
tonsV2 May 12, 2026
a0c6724
feat: proxy VPN traffic to Traefik via socat sidecar
tonsV2 May 12, 2026
fd39eb1
feat: initial WireGuard
tonsV2 May 12, 2026
ed58f2d
chore: get-vpn-ca
tonsV2 May 12, 2026
f821492
docs: vpn updates
tonsV2 May 12, 2026
f89b2e2
fix: restore inventory.ini placeholder host
tonsV2 May 18, 2026
8697243
fix: remove orphan internal.yml from wireguard overlay
tonsV2 May 18, 2026
39c10a3
chore: gitignore rootCA.pem from get-vpn-ca
tonsV2 May 18, 2026
50c4129
fix: restrict internal.key to mode 600
tonsV2 May 18, 2026
9bb3b58
fix: drop unused wildcard domain env from mkcert service
tonsV2 May 18, 2026
76705c8
style: replace em dashes with ASCII dashes
tonsV2 May 18, 2026
d27a16a
docs: document mkcert image digest pin
tonsV2 May 18, 2026
8923c86
fix: generate one cert per internal host
tonsV2 May 18, 2026
c9a21a0
feat: build mkcert image locally instead of pulling pre-built
tonsV2 May 19, 2026
9d58a6a
feat: align server-tools with VPN deployment
Philip-Larsen-Donnelly Jun 2, 2026
67c38f0
fix: handle IPv6 in wireguard
Philip-Larsen-Donnelly Jun 3, 2026
b9b5e85
feat: simplify server deployment
Philip-Larsen-Donnelly Jun 8, 2026
1febdb6
refactor: rename operator_user to docker_user
Philip-Larsen-Donnelly Jun 8, 2026
a257998
refactor: split tunnel default
Philip-Larsen-Donnelly Jun 8, 2026
8bdf2e4
refactor: remove obsolete env template
Philip-Larsen-Donnelly Jun 9, 2026
1f4fb06
fix: refactor wireguard env
Philip-Larsen-Donnelly Jun 9, 2026
d4b3864
Update docs/vpn.md
Philip-Larsen-Donnelly Jun 9, 2026
2ff470d
Merge pull request #102 from dhis2/feat/vpn-server-tools
Philip-Larsen-Donnelly Jun 9, 2026
52015f8
Merge remote-tracking branch 'origin/development-2.0' into feat/vpn
Philip-Larsen-Donnelly Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,12 @@ stacks/monitoring/.env
# Generated route and target files (created by make launch-instance / launch-monitoring)
stacks/traefik/conf.d/*.yml
!stacks/traefik/conf.d/middlewares.yml
!stacks/traefik/conf.d/internal.yml
stacks/monitoring/targets/dhis2/*.json
stacks/monitoring/targets/postgres/*.json

# WireGuard — generated peer configs (contain private keys)
overlays/wireguard/config/

# WireGuard — root CA exported by `make get-vpn-ca`
rootCA.pem
71 changes: 50 additions & 21 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
PRE_COMMIT_VERSION ?= 4.3.0

# Run docker via sudo by default. On a server this is the recommended way to
# start containers: it keeps human operators out of the root-equivalent `docker`
# group while still being able to manage containers (sudo is explicit and logged).
# To run without sudo (e.g. inside a dev container, in CI, or if your user is in
# the docker group), override it:
# make <target> SUDO=
SUDO ?= sudo
DOCKER = $(SUDO) docker

# PROJECT_NAME: unique name for this instance (e.g. dev, test, prod).
# Env file is always instances/$(PROJECT_NAME).env generated by scripts/generate-env.sh.
# Env file is always instances/$(PROJECT_NAME).env - generated by scripts/generate-env.sh.
PROJECT_NAME ?= $(notdir $(CURDIR))
ENV_FILE = instances/$(PROJECT_NAME).env
BACKUP_DIR ?= ./backups/$(PROJECT_NAME)

.PHONY: init playwright test reinit check backup-database backup-file-storage backup restore-database restore-file-storage restore docs generate-stack-envs create-instance list-instances start-postgres start-instance start-traefik start-monitoring ensure-networks stop-instance delete-instance clean clean-all config get-backup-timestamp
.PHONY: init playwright test reinit check backup-database backup-file-storage backup restore-database restore-file-storage restore docs generate-stack-envs create-instance list-instances start-postgres start-instance start-traefik start-monitoring start-vpn stop-vpn get-vpn-ca ensure-networks stop-instance delete-instance clean clean-all config get-backup-timestamp

init:
@test -d .venv || python3 -m venv .venv
Expand All @@ -28,28 +37,29 @@ reinit:
$(MAKE) init

# Generate .env files for stacks/traefik/ and stacks/monitoring/ (run once per server).
# Requires: GEN_LETSENCRYPT_ACME_EMAIL and GEN_GRAFANA_HOSTNAME to be set.
# Example: GEN_LETSENCRYPT_ACME_EMAIL=ops@example.com GEN_GRAFANA_HOSTNAME=grafana.example.com make generate-stack-envs
# Requires: GEN_LETSENCRYPT_ACME_EMAIL to be set.
# Example: GEN_LETSENCRYPT_ACME_EMAIL=ops@example.com make generate-stack-envs
generate-stack-envs:
GEN_LETSENCRYPT_ACME_EMAIL=$(GEN_LETSENCRYPT_ACME_EMAIL) \
GEN_GRAFANA_HOSTNAME=$(GEN_GRAFANA_HOSTNAME) \
./scripts/generate-stack-envs.sh

install-loki-driver:
docker plugin ls --format '{{.Name}}' | grep -q 'loki:latest' || ./scripts/install-loki-driver.sh
docker plugin ls
$(DOCKER) plugin ls --format '{{.Name}}' | grep -q 'loki:latest' || $(SUDO) ./scripts/install-loki-driver.sh
$(DOCKER) plugin ls

check:
.venv/bin/pre-commit run --all-files

BACKUP_TIMESTAMP ?= $(shell date -u +%Y-%m-%d_%H-%M-%S_%Z)

POSTGRES_COMPOSE_CMD = docker compose \
POSTGRES_COMPOSE_CMD = $(DOCKER) compose \
--project-name $(PROJECT_NAME) \
--env-file $(ENV_FILE) \
-f stacks/postgres/docker-compose.yml

BACKUP_COMPOSE_CMD = BACKUP_DIR=$(BACKUP_DIR) docker compose \
# `sudo env BACKUP_DIR=...` (rather than a leading `BACKUP_DIR=... sudo`) so the
# variable survives into docker's environment instead of being stripped by sudo.
BACKUP_COMPOSE_CMD = $(SUDO) env BACKUP_DIR=$(BACKUP_DIR) docker compose \
--project-name $(PROJECT_NAME) \
--env-file $(ENV_FILE) \
-f docker-compose.yml \
Expand Down Expand Up @@ -86,9 +96,9 @@ restore:

docs:
mkdir -p ./docs
docker compose -f stacks/docs/docker-compose.yml run --rm compose-docs > docs/environment-variables.md
$(DOCKER) compose -f stacks/docs/docker-compose.yml run --rm compose-docs > docs/environment-variables.md

COMPOSE_CMD = docker compose \
COMPOSE_CMD = $(DOCKER) compose \
--project-name $(PROJECT_NAME) \
--env-file $(ENV_FILE) \
-f docker-compose.yml \
Expand All @@ -98,18 +108,16 @@ COMPOSE_CMD = docker compose \
# Create the shared Docker networks required by the multi-instance workflow.
# Safe to run multiple times (ignores errors if networks already exist).
ensure-networks:
docker network create proxy 2>/dev/null || true
docker network create monitoring 2>/dev/null || true
$(DOCKER) network create proxy 2>/dev/null || true
$(DOCKER) network create monitoring 2>/dev/null || true

# Start the standalone Traefik gateway (run once; watches stacks/traefik/conf.d/ for route changes)
start-traefik: ensure-networks
docker compose -f stacks/traefik/docker-compose.yml --env-file stacks/traefik/.env up $(COMPOSE_OPTS)
$(DOCKER) compose -f stacks/traefik/docker-compose.yml --env-file stacks/traefik/.env up $(COMPOSE_OPTS)

# Start the standalone monitoring stack (run once; watches stacks/monitoring/targets/ for new instances)
start-monitoring: ensure-networks
GRAFANA_HOSTNAME=$$(grep -E '^GRAFANA_HOSTNAME=' stacks/monitoring/.env | cut -d= -f2-) \
envsubst < stacks/traefik/conf.d/monitoring.yml.template > stacks/traefik/conf.d/monitoring.yml
docker compose -f stacks/monitoring/docker-compose.yml --env-file stacks/monitoring/.env up $(COMPOSE_OPTS)
$(DOCKER) compose -f stacks/monitoring/docker-compose.yml --env-file stacks/monitoring/.env up $(COMPOSE_OPTS)

# Generate the env file for a new instance.
# Example: APP_HOSTNAME=dhis2.example.com PROJECT_NAME=prod make create-instance
Expand All @@ -127,15 +135,15 @@ list-instances:
for env in $$envs; do \
name=$$(basename $$env .env); \
hostname=$$(grep -E '^APP_HOSTNAME=' $$env | cut -d= -f2-); \
running=$$(docker compose --project-name $$name -f docker-compose.yml ps -q 2>/dev/null | wc -l | tr -d ' '); \
running=$$($(DOCKER) compose --project-name $$name -f docker-compose.yml ps -q 2>/dev/null | wc -l | tr -d ' '); \
printf "%-20s %-40s %s running\n" "$$name" "$$hostname" "$$running"; \
done; \
fi

# Start the PostgreSQL stack for a named instance.
# Creates a per-instance db network (PROJECT_NAME-db) and waits until healthy.
start-postgres:
docker network create $(PROJECT_NAME)-db 2>/dev/null || true
$(DOCKER) network create $(PROJECT_NAME)-db 2>/dev/null || true
$(POSTGRES_COMPOSE_CMD) up --wait -d

# Start a named DHIS2 instance connected to the standalone Traefik, monitoring, and postgres stacks.
Expand All @@ -156,7 +164,7 @@ start-instance: ensure-networks install-loki-driver start-postgres
stop-instance:
$(COMPOSE_CMD) down --remove-orphans
$(POSTGRES_COMPOSE_CMD) down
docker network rm $(PROJECT_NAME)-db 2>/dev/null || true
$(DOCKER) network rm $(PROJECT_NAME)-db 2>/dev/null || true
rm -f stacks/traefik/conf.d/$(PROJECT_NAME).yml
rm -f stacks/monitoring/targets/dhis2/$(PROJECT_NAME).json
rm -f stacks/monitoring/targets/postgres/$(PROJECT_NAME).json
Expand All @@ -172,7 +180,7 @@ delete-instance:
fi
$(COMPOSE_CMD) down --remove-orphans --volumes
$(POSTGRES_COMPOSE_CMD) down --volumes
docker network rm $(PROJECT_NAME)-db 2>/dev/null || true
$(DOCKER) network rm $(PROJECT_NAME)-db 2>/dev/null || true
rm -f stacks/traefik/conf.d/$(PROJECT_NAME).yml
rm -f stacks/monitoring/targets/dhis2/$(PROJECT_NAME).json
rm -f stacks/monitoring/targets/postgres/$(PROJECT_NAME).json
Expand All @@ -181,6 +189,27 @@ delete-instance:
clean:
$(COMPOSE_CMD) down --remove-orphans

COMPOSE_CMD_VPN = $(DOCKER) compose -p wireguard --env-file overlays/wireguard/.env -f overlays/wireguard/docker-compose.yml

# Start the standalone WireGuard VPN (run once per server; manages certs and peer configs).
# After first start, retrieve the rootCA.pem from the wireguard-certs Docker volume and
# distribute to VPN clients so they trust the *.internal wildcard certificate.
start-vpn: ensure-networks install-loki-driver
$(COMPOSE_CMD_VPN) up -d wireguard wireguard-proxy
# Traefik's file provider watches conf.d/ for YAML changes but NOT the cert files
# referenced inside them. On first launch the certs don't exist yet when Traefik
# starts, so touching internal.yml forces a reload that picks up the new certs.
touch stacks/traefik/conf.d/internal.yml

stop-vpn:
$(COMPOSE_CMD_VPN) down --remove-orphans

# Export the *.internal root CA from the wireguard-certs volume so it can be installed
# in a client's OS / browser trust store. Writes rootCA.pem to the current directory.
get-vpn-ca:
$(COMPOSE_CMD_VPN) run --rm --entrypoint cat mkcert /certs/rootCA.pem > rootCA.pem
Comment thread
tonsV2 marked this conversation as resolved.
@echo "Wrote rootCA.pem - install it in your OS trust store, then restart your browser."

clean-all:
@if [ -t 0 ]; then \
echo "WARNING: This will destroy all Docker volumes (database, file storage, monitoring data, etc.)."; \
Expand Down
53 changes: 41 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ flowchart LR
Browser -->|"prod.your-domain.com"| Traefik
Browser -->|"dev.your-domain.com"| Traefik
Browser -->|"other.your-domain.com"| Traefik
Browser -->|"grafana.your-domain.com [VPN]"| Mon
Browser -->|"grafana.internal [VPN]"| Mon
Traefik --> App1
Traefik --> App2
Traefik --> App3
Expand Down Expand Up @@ -85,6 +85,7 @@ flowchart LR
- [Traefik Dashboard](#traefik-dashboard)
- [Glowroot](#glowroot)
- [Profiling (Tracing with Tempo)](#profiling-tracing-with-tempo)
- [VPN Access (WireGuard)](#vpn-access-wireguard)
- [Backup and Restore](#backup-and-restore)
- [Backup](#backup)
- [Backup Timestamp](#backup-timestamp)
Expand Down Expand Up @@ -127,7 +128,7 @@ flowchart TD
A(["Start"]) --> B

subgraph once["① One-time host setup"]
B["<b>make generate-stack-envs</b><br/><i>Set your email and Grafana hostname</i>"]
B["<b>make generate-stack-envs</b><br/><i>Set your Let's Encrypt email</i>"]
C["<b>make start-traefik</b><br/><b>make start-monitoring</b>"]
B --> C
end
Expand All @@ -141,7 +142,6 @@ git clone https://github.com/dhis2/docker-deployment.git && \

# One-time host setup: configure and launch Traefik and the monitoring stack
GEN_LETSENCRYPT_ACME_EMAIL=whatever@dhis2.org \
GEN_GRAFANA_HOSTNAME=grafana.127-0-0-1.nip.io \
make generate-stack-envs

make start-traefik &
Expand All @@ -157,7 +157,7 @@ Open [http://dhis2.127-0-0-1.nip.io](http://dhis2.127-0-0-1.nip.io) in your favo
> [!NOTE]
> Your browser will warn you that the certificate is not trusted. This is expected, as it is a self-signed certificate.
>
> For local testing without real DNS, [nip.io](https://nip.io) provides free wildcard DNS that resolves to an embedded IP address for example, `dhis2.127-0-0-1.nip.io` resolves to `127.0.0.1` with no configuration required.
> For local testing without real DNS, [nip.io](https://nip.io) provides free wildcard DNS that resolves to an embedded IP address - for example, `dhis2.127-0-0-1.nip.io` resolves to `127.0.0.1` with no configuration required.

> [!NOTE]
> The default DHIS2 admin credentials are available in `instances/prod.env`.
Expand All @@ -166,6 +166,9 @@ Open [http://dhis2.127-0-0-1.nip.io](http://dhis2.127-0-0-1.nip.io) in your favo

This section is for users planning to deploy DHIS2 in a production environment.

> **Note**
> Check out this [project](./server-tools) if you're deploying on a blank server.

### Deployment Prerequisites

Before deploying to production, ensure you have:
Expand All @@ -176,7 +179,7 @@ Before deploying to production, ensure you have:
- Appropriate firewall rules configured for ports 80 and 443.

> [!NOTE]
> A wildcard DNS record (`*.your-domain.com`) pointing to your server is a convenient way to cover all instances with a single DNS entry each instance then gets its own subdomain (e.g. `prod.your-domain.com`, `dev.your-domain.com`).
> A wildcard DNS record (`*.your-domain.com`) pointing to your server is a convenient way to cover all instances with a single DNS entry - each instance then gets its own subdomain (e.g. `prod.your-domain.com`, `dev.your-domain.com`).
>
> You can also namespace instances under a shared subdomain: add an A record for `dhis2.your-domain.com` and a wildcard `*.dhis2.your-domain.com`, then host `prod` at `dhis2.your-domain.com` and additional instances at `dev.dhis2.your-domain.com`, `test.dhis2.your-domain.com`, etc. Note that a wildcard does not match the bare subdomain it is rooted at, so the explicit A record for `dhis2.your-domain.com` is required alongside the wildcard.
>
Expand All @@ -188,14 +191,15 @@ Run these commands once per host before creating any instances. They generate en

```shell
GEN_LETSENCRYPT_ACME_EMAIL=your@email.com \
GEN_GRAFANA_HOSTNAME=grafana.your-domain.com \
make generate-stack-envs

COMPOSE_OPTS=-d make start-traefik
COMPOSE_OPTS=-d make start-monitoring
```

`COMPOSE_OPTS=-d` runs both stacks in detached mode. Traefik watches `stacks/traefik/conf.d/` for route changes; Prometheus watches `stacks/monitoring/targets/` for new scrape targets — both pick up new instances automatically without a restart.
`COMPOSE_OPTS=-d` runs both stacks in detached mode. Traefik watches `stacks/traefik/conf.d/` for route changes; Prometheus watches `stacks/monitoring/targets/` for new scrape targets - both pick up new instances automatically without a restart.

> Grafana is not exposed publicly. It is reachable at `https://grafana.internal` once you set up the VPN. See the [VPN Access](#vpn-access-wireguard) section.

### Create an instance

Expand All @@ -205,7 +209,7 @@ Generate the environment file for a named instance. `PROJECT_NAME` is a short id
APP_HOSTNAME=<name>.<your-domain.com> PROJECT_NAME=<name> make create-instance
```

This writes `instances/<name>.env` with generated passwords and the supplied hostname. Review and adjust that file before launching — see the [environment variables documentation](docs/environment-variables.md) for details on each variable.
This writes `instances/<name>.env` with generated passwords and the supplied hostname. Review and adjust that file before launching. See the [environment variables documentation](docs/environment-variables.md) for details on each variable.

You can create multiple instances in this way, by simply using different names for each.

Expand Down Expand Up @@ -318,6 +322,30 @@ PROJECT_NAME=<name> COMPOSE_OPTS="-f overlays/profiling/docker-compose.yml" make

For detailed configuration and usage, see the [Profiling Overlay README](overlays/profiling/README.md).

#### VPN Access (WireGuard)

The standalone WireGuard stack provides a private tunnel so authorised clients can reach admin and monitoring UIs (Grafana, Glowroot) over `*.internal` hostnames without exposing them publicly. DHIS2 itself stays public; only admin surfaces move behind the VPN.

This assumes `make generate-stack-envs` has already been run during server setup (it creates `overlays/wireguard/.env`). Edit that file, then start the VPN:

```shell
# in overlays/wireguard/.env, set WIREGUARD_SERVER_URL and WIREGUARD_PEERS
make start-vpn
```

| Hostname | Service |
| ------------------- | ----------- |
| `grafana.internal` | Grafana |
| `glowroot.internal` | Glowroot |

Once the stack is up, export the self-signed root CA for clients to trust:

```shell
make get-vpn-ca # writes rootCA.pem to the current directory
```

Use `make stop-vpn` to tear down the VPN stack. For client setup, peer enrolment, CA installation per OS, and the full architecture, see [docs/vpn.md](docs/vpn.md).

### Backup and Restore

Robust backup and restore procedures are essential for production. Backups are stored in the `./backups` directory. We support backup and restore of both the database and the file storage.
Expand Down Expand Up @@ -423,11 +451,13 @@ DHIS2's built-in monitoring API is enabled, exposing health and performance metr

#### Accessing Monitoring Services

1. Ensure the monitoring stack is running (`make start-monitoring`).
2. Open `https://<GEN_GRAFANA_HOSTNAME>` in your browser (the hostname configured during server setup).
Grafana is only reachable over the WireGuard VPN at `https://grafana.internal`. It is not exposed publicly. See [VPN Access (WireGuard)](#vpn-access-wireguard) for setup.

1. Ensure the monitoring stack is running (`make start-monitoring`) and the VPN is up (`make start-vpn`).
2. Connect to the VPN and open `https://grafana.internal` in your browser.
3. Login with:
- Username: `admin`
- Password: Check `stacks/monitoring/.env` for `GRAFANA_ADMIN_PASSWORD`.
- Password: check `stacks/monitoring/.env` for `GRAFANA_ADMIN_PASSWORD`.

#### Configuration

Expand Down Expand Up @@ -459,7 +489,6 @@ To start all services for development, follow the same flow as production using

```shell
GEN_LETSENCRYPT_ACME_EMAIL=dev@dhis2.org \
GEN_GRAFANA_HOSTNAME=grafana.127-0-0-1.nip.io \
make generate-stack-envs

make start-traefik &
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.d2
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Docker deployment network and service architecture
# Docker deployment - network and service architecture
#
# Generate SVG:
# ~/.local/bin/d2 docs/architecture.d2 docs/architecture.svg
Expand Down
Loading
Loading