Running the Optimizely CMS 13 Alloy Site on macOS with Docker
by Stanisław Szołkowski
In my first Apple Silicon post and the 2025 revisit I covered running an Optimizely Foundation site on an M1/ARM Mac. This time I wanted to try something smaller — the official Alloy template site for Optimizely CMS 13, which ships with a ready-made Docker Compose setup. On Windows it works out of the box, but on macOS with Apple Silicon a few adjustments are needed before everything runs smoothly.
All changes described below are available on my GitHub repository. You can also review the complete list of changes in this pull request.
What is the Alloy Site?
Alloy is the official demo and template site for Optimizely CMS 13 — the latest major version built on .NET 10. It’s the go-to starting point when you want to spin up a new CMS 13 project or just experiment with the latest CMS features. Unlike previous CMS versions that were tightly coupled to Windows and IIS, CMS 13 runs cross-platform on .NET, which makes Docker-based development a natural fit.
To create the Alloy site, first install the Optimizely templates:
dotnet new install EPiServer.Templates
Then scaffold the project with Docker support enabled:
dotnet new epi-alloy-mvc --name alloy-docker --output ./alloy-docker --enable-docker
This gives you a ready-made docker-compose.yml that brings up a SQL Server container and the web application together. The template also creates a .env file with the environment variables (SA_PASSWORD, DB_NAME, DB_DIRECTORY) that docker-compose.yml references, so in theory you should be able to docker compose up and have a working site. In practice, the Docker configuration assumes a Windows/x64 host, and running it on macOS requires a handful of changes.
Clean Up Stale LocalDB Files
The Optimizely template generates .mdf and .ldf database files in the App_Data/ directory that were created on Windows with LocalDB. These files contain internal references to Windows paths (e.g. C:\Users\...\MSSQLLocalDB\empty.ldf) and the Linux SQL Server container can’t use them. They need to be removed before running on macOS:
rm -f App_Data/*.mdf App_Data/*.ldf
Skip this step if your App_Data/ directory doesn’t contain any .mdf/.ldf files.
Upgrading the SQL Server Image
The original db.dockerfile uses mcr.microsoft.com/mssql/server:2019-latest. SQL Server 2019 doesn’t have native ARM images, which means Docker Desktop would run it under x64 emulation — slow and unreliable. SQL Server 2025 ships with native ARM support, so switching to 2025-latest gives us a proper native container.
I also removed the USER root line that was in the original Dockerfile. The mssql user is sufficient for what we need.
FROM mcr.microsoft.com/mssql/server:2025-latest AS base
ENV ACCEPT_EULA=Y
WORKDIR /src
COPY ./Docker/create-db.sh .
RUN chmod +x /src/create-db.sh
USER mssql
EXPOSE 1433
ENTRYPOINT /src/create-db.sh & /opt/mssql/bin/sqlservr
Fixing the Database Creation Script
This was the trickiest issue to track down. The original create-db.sh specified explicit .mdf/.ldf file paths pointing into a host-mounted directory:
CREATE DATABASE [${DB_NAME}]
ON (NAME=[${DB_NAME}_data], FILENAME='/var/opt/mssql/host_data/...')
LOG ON (NAME=[${DB_NAME}_log], FILENAME='/var/opt/mssql/host_data/...');
On macOS, Docker bind mounts don’t grant the mssql container user write access to the host directory. SQL Server fails with OS error 31 (A device attached to the system is not functioning) when trying to create the database files there.
The fix is simple — remove the explicit file paths entirely and let SQL Server use its default internal data directory (/var/opt/mssql/data/), which the mssql user owns:
CREATE DATABASE [${DB_NAME}];
I also added the -b flag to sqlcmd. Without it, SQL errors are printed to stdout but sqlcmd still returns exit code 0, which means the retry loop would silently report success on failure.
#!/bin/bash
echo "Creating database..."
let result=1
for i in {1..100}; do
/opt/mssql-tools18/bin/sqlcmd -b -S localhost -U sa -P "$SA_PASSWORD" -Q "IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = '${DB_NAME}') CREATE DATABASE [${DB_NAME}];" -C
let result=$?
if [ $result -eq 0 ]; then
echo "Creating database completed"
break
else
echo "Creating database. Not ready yet..."
sleep 1
fi
done
Docker Compose Changes
The docker-compose.yml needed three separate adjustments.
Read-Only App_Data Mount
Since we no longer write database files into the host-mounted directory (the CREATE DATABASE now uses SQL Server’s internal path), the App_Data volume only needs to provide the .episerverdata import file. Making it read-only (:ro) makes this explicit:
volumes:
- ./App_Data:/var/opt/mssql/host_data/${DB_DIRECTORY}:ro
Healthcheck for the Database Service
The original configuration used a simple depends_on:
web:
depends_on:
- db
This only waits for the db container to start — not for SQL Server to actually be ready and the database to exist. The web container would attempt to connect too early and crash with Login failed for user 'sa'.
The fix is a healthcheck that verifies the application database actually exists before the web container starts:
db:
healthcheck:
test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$$SA_PASSWORD" -Q "SELECT 1 FROM sys.databases WHERE name = '$$DB_NAME'" -C -h -1 | grep -q 1
interval: 5s
timeout: 5s
retries: 30
start_period: 15s
web:
depends_on:
db:
condition: service_healthy
Note the $$ syntax — Docker Compose requires double dollar signs to reference environment variables inside healthcheck commands (single $ would be interpreted by the compose file parser).
Port Change: 5000 to 5100
Starting with macOS Monterey, Apple uses port 5000 for AirPlay Receiver. If you try to bind to port 5000, it either fails silently or conflicts with the system service. Changing the host port mapping to 5100 avoids this:
ports:
- 5100:80
Here is the complete docker-compose.yml with all changes applied:
version: '3.9'
services:
db:
build:
dockerfile: ./Docker/db.dockerfile
context: .
environment:
SA_PASSWORD: ${SA_PASSWORD}
DB_NAME: ${DB_NAME}
DB_DIRECTORY: ${DB_DIRECTORY}
ports:
- 6000:1433
volumes:
- ./App_Data:/var/opt/mssql/host_data/${DB_DIRECTORY}:ro
image: MyOptiAlloySite/db
healthcheck:
test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$$SA_PASSWORD" -Q "SELECT 1 FROM sys.databases WHERE name = '$$DB_NAME'" -C -h -1 | grep -q 1
interval: 5s
timeout: 5s
retries: 30
start_period: 15s
web:
depends_on:
db:
condition: service_healthy
build:
dockerfile: ./Docker/web.dockerfile
context: .
environment:
ASPNETCORE_URLS: http://*:80
ASPNETCORE_ENVIRONMENT: Development
CONNECTIONSTRINGS__EPISERVERDB: Server=db;Database=${DB_NAME};User Id=sa;Password=${SA_PASSWORD};Encrypt=False;
ports:
- 5100:80
volumes:
- .:/src
image: MyOptiAlloySite/web
restart: on-failure
Web Dockerfile Port Update
To match the port change in docker-compose.yml, the web.dockerfile also needs its EXPOSE directive updated from 5000/5001 to 5100/5101:
FROM mcr.microsoft.com/dotnet/sdk:10.0
WORKDIR /src
COPY MyOptiAlloySite.csproj .
COPY Directory.Build.props .
COPY nuget.config .
RUN dotnet restore
EXPOSE 80 443 5100 5101
ENTRYPOINT ["dotnet", "run", "--no-launch-profile"]
Running It
With all changes in place, start everything up:
docker compose up
The first run will take a moment — Docker needs to build the images, restore NuGet packages, and initialize the database. Once you see the healthcheck passing and the web container starting, open http://localhost:5100 in your browser.
Summary
Getting the Optimizely CMS 13 Alloy site running on macOS with Docker required six changes:
- Delete stale LocalDB files — remove
.mdf/.ldffiles fromApp_Data/generated by the template - Upgrade SQL Server — switch from
2019-latestto2025-latestfor native ARM support - Simplify the DB creation script — remove explicit file paths and add the
-bflag for proper error handling - Read-only App_Data mount — the volume only provides the import file, not database storage
- Add a healthcheck — ensure the database is ready before the web container starts
- Change ports — move from 5000 to 5100 to avoid the macOS AirPlay Receiver conflict
All changes are on my GitHub repository if you want to see the full code. Let me know in the comments if you run into any other issues!