The Big Picture: The Philosophy Behind Our Setup
Disclaimer: Only tested on Mac Silicon
If you're ready to integrate a headless CMS into your project, you're in the right place. We're going to build a solid foundation for local development, and we'll do it by keeping our environment as close to production as possible.
My projects consistently use NGINX—even for local development. For my reasoning, you can read more here:
Nginx for Local Development: Really?
The Red Pill of Software Delivery: Unmasking Magic Code and Building for Reality
The gist of it is this: I'll be showcasing how to set up a development environment for a Single Page Application (SPA) and a backend running in a devcontainer, using Squidex as our headless CMS.
To keep this environment close to a real-world scenario, we'll be using local domain names. This means we'll have one domain for our main frontend (the SPA) and another for the Squidex administration app, which is also a SPA.
This approach gives us a reliable, consistent, and realistic development setup that will make our transition to production much smoother.
The Development Environment
As you can see overview we will be routing all of our development server endpoints though Nginx.
Let's start building our devcontainer environment!
The devcontainer
Since this post is all about how to get started using Squidex I won't be covering much about how to setup a devcontainer.
.devcontainer/devcontainer.json
{
"name": "Dotnet 9 and Elm Dev Container (Alpine + Compose)",
"dockerComposeFile": "docker-compose.yml",
"service": "dev",
"workspaceFolder": "/workspace",
"mounts": [
"source=my-app-elm-devcontainer,target=/home/container-user/.elm,type=volume"
],
"customizations": {
"vscode": {
"settings": {
"terminal.integrated.defaultProfile.linux": "zsh"
},
"extensions": [
"ms-vscode-remote.remote-containers",
"Elmtooling.elm-ls-vscode",
"ms-dotnettools.csharp",
"william-voyek.vscode-nginx",
"vscodevim.vim",
"ms-dotnettools.csdevkit",
"EditorConfig.EditorConfig",
"humao.rest-client",
"esbenp.prettier-vscode",
"DotJoshJohnson.xml",
"streetsidesoftware.code-spell-checker",
"streetsidesoftware.code-spell-checker-danish",
"bradlc.vscode-tailwindcss",
"kamikillerto.vscode-colorize",
"Ionide.Ionide-fsharp",
"ms-azuretools.vscode-containers",
"jebbs.plantuml"
]
}
},
"remoteUser": "container-user"
}
Dockerfile.omnia
# Use a stable version of Alpine as the base image
FROM alpine:3.21.0
# Set up the working directory
WORKDIR /workspace
# Set environment variables
ENV HOME_DIR="/home/container-user"
# ENV LV_BRANCH="release-1.4/neovim-0.9"
ENV PATH="$PATH:$HOME_DIR/.local/bin"
# Install dependencies
RUN apk update && \
apk add --no-cache \
python3 \
py3-pip \
gcc \
musl-dev \
python3-dev \
libffi-dev \
openssl-dev \
cargo \
make \
yarn \
git \
openssh \
neovim \
neovim-doc \
xclip \
ripgrep \
alpine-sdk \
dotnet9-sdk \
bash \
zsh \
tree \
stow \
unzip \
nushell \
tmux \
grep \
jq \
curl \
nodejs \
npm \
sudo \
libc6-compat # Install if needed for compatibility with Elm binaries
# Configuring Elm version
ARG ELM_VERSION=latest-0.19.1
ARG ELM_TEST_VERSION=latest-0.19.1
ARG ELM_FORMAT_VERSION=latest-0.19.1
# This Dockerfile adds a non-root user with sudo access.
ARG USERNAME=container-user
ARG USER_UID=1000
ARG USER_GID=$USER_UID
# Add a non-root user and group
RUN addgroup -S $USERNAME && \
adduser -S $USERNAME -G $USERNAME --shell /bin/sh
# Install Elm using the provided method, elm-test and elm-format via npm
RUN export DEBIAN_FRONTEND=noninteractive && \
# Install Elm binary
curl -L -o elm.gz https://github.com/elm/compiler/releases/download/0.19.1/binary-for-linux-64-bit.gz && \
gunzip elm.gz && \
chmod +x elm && \
mv elm /usr/local/bin/elm && \
# Install elm-test and elm-format via npm
npm install --global \
elm-test@${ELM_TEST_VERSION} \
elm-format@${ELM_FORMAT_VERSION} \
elm-watch@beta && \
# [Optional] Update UID/GID if needed
if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
groupmod --gid $USER_GID $USERNAME && \
usermod --uid $USER_UID --gid $USER_GID $USERNAME && \
chown -R $USER_UID:$USER_GID /home/$USERNAME; \
fi && \
# Create the elm cache directory where we can mount a volume
mkdir /home/$USERNAME/.elm && \
chown $USERNAME:$USERNAME /home/$USERNAME/.elm && \
# Create the .azure directory
mkdir -p /home/container-user/.azure && \
chown $USERNAME:$USERNAME /home/$USERNAME/.azure
ENV ENV=/home/$USERNAME/.profile
USER $USERNAME
RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" || true
RUN git clone --depth=1 https://github.com/romkatv/powerlevel10k.git /home/$USERNAME/.oh-my-zsh/custom/themes/powerlevel10k
RUN dotnet tool install --global csharp-ls
ENV PATH="$PATH:~/.dotnet/tools"
USER root
ENV PATH="/opt/venv/bin:$PATH"
docker-compose.yml
services:
dev:
build:
context: .
dockerfile: Dockerfile.omnia
volumes:
# 'cached' optimizes mount performance on macOS/Windows.
- ../..:/workspace:cached
- my-app-elm-devcontainer:/home/container-user/.elm
command: sleep infinity
networks:
- internal
nginx:
image: nginx:alpine
container_name: my-app_nginx
ports:
- "80:80"
- "443:443"
- "8314:80"
networks:
- internal
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- dev
- squidex
mongo:
image: "mongo:6"
volumes:
- my-app_mongo_data:/data/db
networks:
- internal
restart: unless-stopped
squidex:
image: "squidex/squidex:7"
ports:
- "8376:5000"
environment:
- URLS__BASEURL=https://squidex.my-app.dk
- IDENTITY__ALLOWHTTPSCHEME=false
- EVENTSTORE__MONGODB__CONFIGURATION=mongodb://mongo
- STORE__MONGODB__CONFIGURATION=mongodb://mongo
- IDENTITY__ADMINEMAIL=sukkerfrit@gmail.com
- IDENTITY__ADMINPASSWORD=Lucas2007!
- ASPNETCORE_URLS=http://+:5000
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/healthz"]
start_period: 60s
depends_on:
- mongo
volumes:
- my-app_squidex_assets:/app/Assets
networks:
- internal
restart: unless-stopped
volumes:
my-app-elm-devcontainer:
my-app_squidex_assets:
my-app_mongo_data:
networks:
internal:
driver: bridge
And now for the quite important nginx.conf:
worker_processes 1;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# Define resolver to handle host.docker.internal
resolver 127.0.0.11;
server {
listen 80;
server_name localhost my-app.dk;
location /api/ {
set $backend_upstream host.docker.internal:5130;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://$backend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
location /cms/ {
set $backend_upstream host.docker.internal:5140;
rewrite ^/cms/(.*)$ /$1 break;
proxy_pass http://$backend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Proxy frontend requests to Elm dev server
location / {
set $frontend_upstream host.docker.internal:3033;
proxy_pass http://$frontend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
sub_filter '</head>' '<meta name="environment-name" content="local" /></head>';
sub_filter_once on;
# Only apply sub_filter to index.html
# sub_filter_types text/html;
}
location = /robots.txt {
set $frontend_upstream host.docker.internal:3033;
proxy_pass http://$frontend_upstream/robots.txt;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location = /humans.txt {
set $frontend_upstream host.docker.internal:3033;
proxy_pass http://$frontend_upstream/humans.txt;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
}
server {
listen 80;
server_name squidex.my-app.dk;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name squidex.my-app.dk;
ssl_certificate /etc/nginx/ssl/squidex.crt;
ssl_certificate_key /etc/nginx/ssl/squidex.key;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://squidex:5000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
# Authentication flows
proxy_cookie_path / /;
}
}
}
We are not done yet!
You'll need to:
- Create your own certs (.devcontainer/ssl/...)
- Update your hosts file (/ect/hosts)
- Remove or add your own versions of the app's for
/api/
and/cms/
This should get you up and running with the basics of Squidex!
Security /cms/
I just create a wrapper/proxy for the Squidex api. This is done in order to keep the Client Secret - well secret. In production I use the role Reader for public stuff like Blog's, faq's etc.
If you want the code for the proxy/wrapper do write me.
Testing the setup with custom domain name
From your host machine eg. run:
curl -v http://my-app.xyz/cms/proxy/api/content/my-app/blog
curl -v -k https://squidex.my-app.xyz/squidex/app
Tust me bro!
Make your host machines browsers trust your self-signed cert for the test domain name
On Mac:
- Open Keychain Access
- I used 'login' as Default Keychain
- File -> Import Items
- Import the created cert
- Set the trust level (Double click on the file)
Preparing for Production
When we have published all our containers we are ready to create yet another docker-compose file. This one is for testing the entire system as production like as possible.
That means we will be setting up:
- A completely new
docker-compose.my-app.yml
- A completely revised
nginx.conf
- Create new a certificate just for this test (Could be omitted)
Create a Certificate for multiple domain names
.... Coming soon ....