Deploy with Docker
This is the self-hosted path: you run dotMARC yourself, with PostgreSQL and (optionally) a Caddy reverse proxy for automatic HTTPS, on your own hardware, a home/office server, or a cloud VPS. Prefer a managed platform instead? See Deploy to Azure. Both paths need the two Entra app registrations from Getting Started first.
What you'll need
- Docker installed and running - Docker Desktop on Windows/Mac, or Docker Engine on Linux.
- A domain name (or a subdomain of one you already own) to point at wherever dotMARC will run -
for example
dotmarc.example.uk. You'll need this before you start: Entra's sign-in redirect and the TLS certificate in step 2 both depend on it. - Somewhere for dotMARC to run continuously - a server or NAS on your home or office network, or a cloud VPS/VM. Both are covered below.
1. Start dotMARC and PostgreSQL
$env:GRAPH_CLIENT_ID = '...'
$env:GRAPH_TENANT_ID = '...'
$env:GRAPH_CLIENT_SECRET = '...'
$env:GRAPH_MAILBOX_ADDRESS = '...'
$env:ENTRAID_TENANT_ID = '...'
$env:ENTRAID_CLIENT_ID = '...'
$env:ENTRAID_CLIENT_SECRET = '...'
$env:INITIAL_ADMIN_EMAILS = '...'
docker compose up
This runs dotMARC and a PostgreSQL 18 database together, with Postgres data persisted in a named
Docker volume (dotmarc-postgres-data). INITIAL_ADMIN_EMAILS is how you set the
InitialAdmins__Emails config value described in Getting Started
via Docker Compose specifically - docker-compose.yml maps this shell variable onto the
InitialAdmins__Emails app setting, so the naming differs in casing/format even though it's the
same setting.
Typing all those $env: lines every time gets old fast. Instead, create a file named .env (no
filename before the dot) next to docker-compose.yml - Compose reads it automatically, so you can
just run docker compose up on its own from then on:
GRAPH_CLIENT_ID=11111111-1111-1111-1111-111111111111
GRAPH_TENANT_ID=22222222-2222-2222-2222-222222222222
GRAPH_CLIENT_SECRET=your-mailbox-app-client-secret
ENTRAID_TENANT_ID=22222222-2222-2222-2222-222222222222
ENTRAID_CLIENT_ID=33333333-3333-3333-3333-333333333333
ENTRAID_CLIENT_SECRET=your-dashboard-app-client-secret
(No quotes, no $env: prefix - just KEY=value, one per line. The GUIDs above are placeholders;
use the real client/tenant IDs from Getting Started.)
2. Make dotMARC reachable from the internet
dotMARC itself only speaks plain HTTP, on port 8080 inside the container - fine for testing on your
own machine at http://localhost:8080, but two more things need to be true before real sign-in and
DMARC report delivery will work: your dashboard needs a real hostname with a valid HTTPS
certificate, and that hostname needs to actually be reachable from the internet. This section walks
through both, step by step.
2a. Point your domain at your server
Add a DNS A record for the hostname you chose above (e.g. dotmarc.example.uk), pointing at
your server's public IP address.
- Don't know your public IP? Visit a site like whatismyipaddress.com from the same network your server is on.
- Home or office internet connection: most residential and small-business connections don't have a fixed IP address - it can change without warning. If yours does, use a free Dynamic DNS service (e.g. DuckDNS, or your router's built-in DDNS feature if it has one) instead of a plain A record - it keeps your hostname pointed at your current IP automatically.
- Cloud VPS/VM: these almost always come with a fixed IP already, so a plain A record is fine.
2b. Open ports 80 and 443
The internet needs a path to your server on port 80 (used briefly to prove you control the domain when requesting a certificate) and port 443 (regular HTTPS traffic). How you open that path depends on where dotMARC is running:
Home or office server, behind a router:
- Open your router's admin page in a browser - commonly
http://192.168.1.1orhttp://192.168.0.1(check the sticker on the router, or its manual, if neither works). - Find the Port Forwarding section - sometimes labeled NAT, Virtual Server, or Applications & Gaming; the exact name and location vary by router brand.
- Add two forwarding rules, both pointing at your Docker host's local (LAN) IP address:
- External/WAN port
80→ Internal/LAN port80 - External/WAN port
443→ Internal/LAN port443
- External/WAN port
- Save the changes - some routers need a restart to apply them.
Cloud VPS or virtual machine:
Open inbound access to TCP ports 80 and 443 in whatever firewall your provider uses:
- Plain Linux firewall (
ufw):sudo ufw allow 80,443/tcp - AWS: add inbound rules for TCP 80 and 443 to the instance's Security Group
- Azure VM: add inbound port rules for 80 and 443 to the VM's Network Security Group
- DigitalOcean and most others: look for Firewall in the control panel and add rules allowing TCP 80/443 in
2c. Put Caddy in front of dotMARC
Caddy is a reverse proxy that gets you a real, automatically-renewing HTTPS certificate (via Let's Encrypt) with almost no configuration - the easiest option if you don't already run a reverse proxy for something else on this machine.
The repo ships a complete, ready-to-run stack for this: docker-compose.selfhosted.yml, a
standalone alternative to the plain docker-compose.yml from step 1 that adds Caddy, already fully
wired up. Nothing in it needs hand-editing - stop whatever you started in step 1 first
(docker compose down), then add one more line to the same .env file from step 1:
DOTMARC_HOSTNAME=dotmarc.example.uk
(Replace dotmarc.example.uk with your own hostname from step 2a - this is the only thing that
changes between deployments; everything else in docker-compose.selfhosted.yml and its
Caddyfile.selfhosted is generic.)
Then run:
docker compose -f docker-compose.selfhosted.yml up -d
That's it. Caddy requests and renews a certificate for DOTMARC_HOSTNAME automatically, and
already sends the X-Forwarded-For/X-Forwarded-Proto headers dotMARC needs to know it's being
reached over HTTPS - which is exactly what Entra's redirect-URI check depends on (without it,
sign-in fails with AADSTS50011). Certificate issuance only succeeds once ports 80/443 are genuinely
reachable from the internet (step 2b), since that's how Let's Encrypt confirms you control the
domain.
Skip this file entirely and keep using the plain docker-compose.yml from step 1 (which only
publishes port 8080, no TLS) - instead, point your existing reverse proxy at app:8080 (if it's
on the same Docker network) or localhost:8080 (if you publish that port). Whatever you use just
needs to forward X-Forwarded-For and X-Forwarded-Proto. Caddyfile.selfhosted in this repo is
still a useful reference for the exact config shape, even if you're not running it directly.
2d. Check it worked
From a device on a different network than your server - your phone on mobile data works well,
not the same Wi-Fi - browse to https://dotmarc.example.uk (your own hostname). You should see
dotMARC's sign-in page with a valid padlock icon and no certificate warning.
- Connection times out or refuses to connect: re-check step 2b (the ports aren't actually open).
- Certificate warning/error: re-check step 2a (DNS isn't pointing at the right IP yet), or give Caddy another minute or two to finish requesting the certificate.
Update or redeploy dotMARC
This is the Docker part of Update or redeploy dotMARC, which also covers what to check before and after an update.
How to update depends on whether Compose builds dotMARC from this repository (the supplied
docker-compose.yml and docker-compose.selfhosted.yml) or your own deployment runs the published
GHCR image directly. In every case the PostgreSQL data is in a named volume and must be retained.
Do not add --volumes to docker compose down, and do not delete the Postgres volume during an
application update.
Supplied Compose files: rebuild from the repository
The supplied Compose files use build:, not image:, for the app service. Pull the desired
source revision, rebuild the app image (including an updated .NET base image where available), then
recreate only the application container. PostgreSQL and Caddy keep running:
# Use docker-compose.yml for the plain stack, or docker-compose.selfhosted.yml for Caddy/HTTPS.
$COMPOSE_FILE = 'docker-compose.selfhosted.yml'
git pull --ff-only
docker compose -f $COMPOSE_FILE build --pull app
docker compose -f $COMPOSE_FILE up -d --no-deps app
docker compose -f $COMPOSE_FILE ps
docker compose -f $COMPOSE_FILE logs --tail 100 app
To deploy a particular release instead of the newest main, check out its tag before rebuilding:
git fetch --tags
$VERSION = '<release-version>'
git checkout "v$VERSION"
docker compose -f $COMPOSE_FILE build --pull app
docker compose -f $COMPOSE_FILE up -d --no-deps app
The app applies EF Core migrations during startup. Once the container is healthy, load the dashboard and confirm the expected version in its footer.
Custom Compose file using the published image
If your own Compose file has an image: entry such as
ghcr.io/homotechsual/dotmarc:<release-version>, update that value (or the environment variable supplying it),
then pull and recreate the app service:
$COMPOSE_FILE = 'compose.yml'
docker compose -f $COMPOSE_FILE pull app
docker compose -f $COMPOSE_FILE up -d --no-deps --force-recreate app
docker compose -f $COMPOSE_FILE images app
docker compose -f $COMPOSE_FILE logs --tail 100 app
docker compose pull only downloads the image; it does not replace the already-running container.
The up --force-recreate step is what starts a container from the newly pulled image.
If the image is tagged :latest
docker compose up -d alone may keep the existing local image and container. Explicitly pull the
mutable tag, then force-recreate the app service:
docker compose -f $COMPOSE_FILE pull app
docker compose -f $COMPOSE_FILE up -d --no-deps --force-recreate app
docker compose images app shows the local image ID used by the recreated service, but the string
:latest still does not identify a release. Check the version in dotMARC's footer. Prefer an
immutable release tag in production so it is clear what is running and what to roll back to.
Standalone docker run
Pulling a new image does not alter an existing container. Record the current container's image and run options, pull the desired release, then replace the application container using the same environment variables, network, ports and restart policy as before:
docker inspect --format '{{.Config.Image}}' dotmarc
$VERSION = '<release-version>'
docker pull "ghcr.io/homotechsual/dotmarc:$VERSION"
docker stop dotmarc
docker rm dotmarc
# Re-run your original docker run command, changing only the image tag.
docker run --name dotmarc ... "ghcr.io/homotechsual/dotmarc:$VERSION"
Do not remove the separately managed PostgreSQL container or its volume.
Roll back
For Compose with a published image, restore the previous release tag, pull it and force-recreate the app service. For the supplied build-based Compose files, check out the previous Git release tag and rebuild. As with Azure, do not roll back blindly after a release that applied database migrations: the older app must remain compatible with the current schema.
What's next
Want dotMARC to host MTA-STS policies for your customers' domains? Caddyfile.selfhosted (used
above) already includes everything that needs - see MTA-STS: hosting infrastructure
for the couple of settings to turn it on.
Point each monitored domain's DMARC record's rua= tag at the same mailbox this app polls, e.g.:
v=DMARC1; p=quarantine; rua=mailto:[email protected]
See Getting Started for what the dashboard shows once reports start arriving. Next: DNS Provider Push to let dotMARC fix DNS records for you, or Local Development to run and test from source.