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 ....
Top comments (0)