Running Sitecore JSS with Node Headless SSR Proxy in Sitecore 10, all in Docker – This is the Way

Dear fellow sitecorians, Christmas is near. Sitecore has given us early Christmas presents this year. Sitecore 10, Fully Docker support, Sitecore running in Asp.Net Core, Sitecore JSS 15 and so much more – It’s a developer’s Christmas 🙂
Check out the and be marveled

Today is the 13:th of December, which means we(here in Sweden) are celebrating St. Lucia.

We will eat a lot of lussekatter(Saffron bun) and listen to some lovely Lucia songs. Want to know more about Lucia? Check out this link:

Anyway, let’s proceed.

Today’s post will be how to set up a Node Headless SSR Proxy in a fully running docker environment. Meaning a Sitecore 10 setup running in docker with your favorite JSS app using SSR Proxy.

I couldn’t have said it better myself, Kuiil.
*I’m a big fan of Mandalorian, what a show. A must-see for all Star Wars fans out there.

Yes, we are doing this in docker…

We are not savages living in the stone age. We adapt and evolve to the DOCKER era.

Tony Mamedbekov, has a great post about running ssr proxy on docker – Headless SSR Proxy Sitecore JSS

Our scenario though is that we are running EVERYTHING in docker, including the Sitecore instance together with traefik.

Ok, so where do we start? Sitecore has excellent documentation, so let’s have a look at the Headless SSR via sitecore-jss-proxy. And follow the instructions

1. We need to clone/copy the samples/node-headless-ssr-proxy.

2. We should also ensure that the layoutServiceHost in the app’s scjssconfig.json is set to the hostname of the node-headless-ssr-proxy proxy. If you guys are using graphQl you will need to make a fix here. I spent soo many hours figuring out this annoying part. Let us come back to that one later.

3. Copy the production build artifacts from your app to the proxy (i.e. /node-headless-ssr-proxy/dist/MyApp). The path copied to should match the relative path that is set in the sitecoreDistPath config in the app’s package.json file (/dist/MyApp in the previous example).

4. Set bundlePath to the path to your built JSS app’s server.bundle.js, i.e. ‘./dist/myappname/server.bundle’

5. Set apiHost to your Sitecore instance host and the apiKey to your SSC API Key (see server setup if you don’t have an API key yet)

6. Set apiKey to your Sitecore SSC API key

7. Set the dictionary service path in createViewBag() to your app’s dictionary service URL. If you are not using dictionaries, you can remove the whole createViewBag() function, which enables dictionary caching.
Here we need to make a change, we will have to use HTTP instead of HTTPS. Let’s visit this later

8. Open a command line in your node-headless-ssr-proxy folder, and run npm install then npm start.

Tip: Media URLs in headless mode
The Layout Service will return URLs to images with the Sitecore server URL included.
In headless mode it is possible to use the headless proxy without exposing the Sitecore server publicly, which makes media URLs that contain the Sitecore server URL problematic. It is easy enough to tell Sitecore to not include the server as part of media requests…

And here is a crucially important fact, if you are running graphQl queries returning images and using the jss field. Be aware that it returns the server URL(not relative path), meaning it will fail… Here you need to make some fixes in your graphQl query. Let us come back to that later

Quite straight forward, no? This will work like a charm(when you are not running it all in docker).

First out, the frontend part:
In order to make it work, I had to do some changes to the JSS app. And damn, it was not easy. Tons and tons of hair-pulling before I found out what the issue was. So let me spare you the trouble by telling you, in step 2 in the instructions above, you should also ensure that the layoutServiceHost in the app’s scjssconfig.json is set to the hostname. Something like this:

"layoutServiceHost": "https://myapp.lovely.localhost"

*Notice the https

If you are using graphQL you will get an epic fail, something like this:
[NodeInvocationException: Network error: request to https://myapp.lovely.localhost/api/myapp?sc_apikey={A KEY} failed, reason: getaddrinfo ENOTFOUND myapp.lovely.localhost
Error: Network error: request to https://myapp.lovely.localhost/api/myapp?sc_apikey={A KEY} failed, reason: getaddrinfo ENOTFOUND myapp.lovely.localhost

In the generate-config.js you will find the culprit, check out the last line where the baseConfig.graphQLEndpoint is set. You should instead use the “internal” docker network here, meaning HTTP instead of HTTPS.

 * Adds the GraphQL endpoint URL to the config object, and ensures that components needed to calculate it are valid
function addGraphQLConfig(baseConfig) {
  if (!baseConfig.graphQLEndpointPath || typeof baseConfig.sitecoreApiHost === 'undefined') {
      'The `graphQLEndpointPath` and/or `layoutServiceHost` configurations were not defined. You may need to run `jss setup`.'

  // eslint-disable-next-line no-param-reassign
  baseConfig.graphQLEndpoint = `${baseConfig.sitecoreApiHost}${baseConfig.graphQLEndpointPath}?sc_apikey=${baseConfig.sitecoreApiKey}`;

We will introduce the new variable, graphQLApiHost, which will be set like this in the scjssconfig.json:

"graphQLApiHost": "http://myapp.lovely.localhost"

*Notice the http

And here is the updated function, addGraphQLConfig:

 * Adds the GraphQL endpoint URL to the config object, and ensures that components needed to calculate it are valid
function addGraphQLConfig(baseConfig) {
  if (!baseConfig.graphQLEndpointPath || typeof baseConfig.sitecoreApiHost === 'undefined') {
      'The `graphQLEndpointPath` and/or `layoutServiceHost` configurations were not defined. You may need to run `jss setup`.'

  // eslint-disable-next-line no-param-reassign
  baseConfig.graphQLEndpoint = `${baseConfig.graphQLApiHost}${baseConfig.graphQLEndpointPath}?sc_apikey=${baseConfig.sitecoreApiKey}`;

To add a new config variable, just update the generate-config.js file. Add the new variable name to the methods, generateConfig and transformScJssConfig.

const fs = require('fs');
const path = require('path');
const packageConfig = require('../package.json');

/* eslint-disable no-console */

 * Generate config
 * The object returned from this function will be made available by importing src/temp/config.js.
 * This is executed prior to the build running, so it's a way to inject environment or build config-specific
 * settings as variables into the JSS app.
 * NOTE! Any configs returned here will be written into the client-side JS bundle. DO NOT PUT SECRETS HERE.
 * @param {object} configOverrides Keys in this object will override any equivalent global config keys.
module.exports = function generateConfig(configOverrides) {
  const defaultConfig = {
    sitecoreApiKey: 'no-api-key-set',
    sitecoreApiHost: '',
    graphQLApiHost: '',
    jssAppName: 'Unknown',

  // require + combine config sources
  const scjssConfig = transformScJssConfig();
  const packageJson = transformPackageConfig();

  // optional:
  // do any other dynamic config source (e.g. environment-specific config files)
  // Object.assign merges the objects in order, so the
  // package.json config can override the calculated config,
  // scjssconfig.json overrides it,
  // and finally config passed in the configOverrides param wins.
  const config = Object.assign(defaultConfig, scjssConfig, packageJson, configOverrides);

  // The GraphQL endpoint is an example of making a _computed_ config setting
  // based on other config settings.

  const configText = `/* eslint-disable */
// Do not edit this file, it is auto-generated at build time!
// See scripts/bootstrap.js to modify the generation of this file.
module.exports = ${JSON.stringify(config, null, 2)};`;

  const configPath = path.resolve('src/temp/config.js');

  console.log(`Writing runtime config to ${configPath}`);

  fs.writeFileSync(configPath, configText, { encoding: 'utf8' });

function transformScJssConfig() {
  // scjssconfig.json may not exist if you've never run setup
  // so if it doesn't we substitute a fake object
  let config;
  try {
    // eslint-disable-next-line global-require
    config = require('../scjssconfig.json');
  } catch (e) {
    return {};

  if (!config) return {};

  return {
    sitecoreApiKey: config.sitecore.apiKey,
    sitecoreApiHost: config.sitecore.layoutServiceHost,
    graphQLApiHost: config.sitecore.graphQLApiHost

function transformPackageConfig() {
  if (!packageConfig.config) return {};

  return {
    jssAppName: packageConfig.config.appName,
    defaultLanguage: packageConfig.config.language || 'en',
    graphQLEndpointPath: packageConfig.config.graphQLEndpointPath || null,

 * Adds the GraphQL endpoint URL to the config object, and ensures that components needed to calculate it are valid
function addGraphQLConfig(baseConfig) {
  if (!baseConfig.graphQLEndpointPath || typeof baseConfig.sitecoreApiHost === 'undefined') {
      'The `graphQLEndpointPath` and/or `layoutServiceHost` configurations were not defined. You may need to run `jss setup`.'

  // eslint-disable-next-line no-param-reassign
  baseConfig.graphQLEndpoint = `${baseConfig.graphQLApiHost}${baseConfig.graphQLEndpointPath}?sc_apikey=${baseConfig.sitecoreApiKey}`;

If we are running graphql queries in the app, we need to do some changes(if the query returns images). We need the graphQl to not return the full server URL for the images. Normally you would probably use the jss field:

someimage {

But I’m not sure if it’s possible to add parameters to the jss field, but I do know however it’s possible to do so to the rendered field. Like this:

someimage {

This will return an image element with a relative path to the image.

Next is the backend part.
No changes here, meaning we will follow the instructions from Sitecore. We want the layout service to return relative image paths. We will do this with the following config patch:

<configuration xmlns:patch=""
  <sitecore role:require="ContentDelivery">
        <config name="jss">


We will also add our graphQL config. Something like this:

<?xml version="1.0"?>
<configuration xmlns:patch=""
  <sitecore role:require="ContentManagement or ContentDelivery">
        Define the app's Sitecore GraphQL API endpoint
        Note: this can be removed if you are not using GraphQL.
        Note: the endpoint must be defined both for integrated and connected type GraphQL queries.
          <MyAppGraphQLEndpoint url="/api/myapp" type="Sitecore.Services.GraphQL.Hosting.GraphQLEndpoint, Sitecore.Services.GraphQL.NetFxHost" resolve="true">



            <!-- lock down the endpoint when deployed to content delivery -->
            <graphiql role:require="ContentDelivery">false</graphiql>
            <enableSchemaExport role:require="ContentDelivery">false</enableSchemaExport>
            <enableStats role:require="ContentDelivery">false</enableStats>
            <enableCacheStats role:require="ContentDelivery">false</enableCacheStats>
            <disableIntrospection role:require="ContentDelivery">true</disableIntrospection>

            <schema hint="list:AddSchemaProvider">
              <content type="Sitecore.Services.GraphQL.Content.ContentSchemaProvider, Sitecore.Services.GraphQL.Content">
                <!-- scope typed template generation to just this app's templates -->
                <templates type="Sitecore.Services.GraphQL.Content.TemplateGeneration.Filters.StandardTemplatePredicate, Sitecore.Services.GraphQL.Content">
                  <paths hint="list:AddIncludedPath">
                    <templates>/sitecore/templates/PATH TO APP SPECIFIC TEMPLATES</templates>
                  <fieldFilter type="Sitecore.Services.GraphQL.Content.TemplateGeneration.Filters.StandardFieldFilter, Sitecore.Services.GraphQL.Content">
                    <exclusions hint="raw:AddFilter">
                          Remove system fields from the API (e.g. __Layout) to keep the schema lean
                      <exclude name="__*" />

                <queries hint="raw:AddQuery">
                  <!-- enable querying on items via this API -->
                  <query name="item" type="Sitecore.Services.GraphQL.Content.Queries.ItemQuery, Sitecore.Services.GraphQL.Content" />

                <fieldTypeMapping ref="/sitecore/api/GraphQL/defaults/content/fieldTypeMappings/standardTypeMapping" />

            <!-- Enables the 'jss' graph nodes that are preformatted to use with JSS rendering components, and the datasource resolving queries for JSS -->
            <extenders hint="list:AddExtender">
              <layoutExtender type="Sitecore.JavaScriptServices.GraphQL.JssExtender, Sitecore.JavaScriptServices.GraphQL" resolve="true" />

            <!-- Determines the security of the service. Defaults are defined in Sitecore.Services.GraphQL.config -->
            <security ref="/sitecore/api/GraphQL/defaults/security/systemService" />

            <!-- Determines how performance is logged for the service. Defaults are defined in Sitecore.Services.GraphQL.config -->
            <performance ref="/sitecore/api/GraphQL/defaults/performance/standard" />

                            Cache improves the query performance by caching parsed queries.
                            It is also possible to implement query whitelisting by implementing an authoritative query cache;
                            WhitelistingGraphQLQueryCache is an example of this, capturing queries to files in open mode and allowing only captured queries in whitelist mode.
            <cache type="Sitecore.Services.GraphQL.Hosting.QueryTransformation.Caching.GraphQLQueryCache, Sitecore.Services.GraphQL.NetFxHost">
              <param desc="name">$(url)</param>
              <param desc="maxSize">10MB</param>



But what about the sites config? Well we are using the lovely SXA JSS. It will all be set in Sitecore(in the JssSite node):

The final(and larger part) – Docker setup/changes
The setup in docker… Here we can use the out of box setup that Sitecore provides. Download the Container Deployment Package from the Sitecore Experience Platform 10.0 page and follow instructions.
*Why not choose the xp1 setup.

We will create a cert for myapp.lovely.localhost and put it in the Traefik/certs folder. According to Readme, it says – Add TLS certificates for …. hosts to this folder. It is not much help. Instead, check out the great Helix example project at GitHub. Locate core example project –
Open up the init.ps1, here you will find how to make the cert and also update the hosts file.

# Configure TLS/HTTPS certificates

Push-Location docker\traefik\certs
try {
    $mkcert = ".\mkcert.exe"
    if ($null -ne (Get-Command mkcert.exe -ErrorAction SilentlyContinue)) {
        # mkcert installed in PATH
        $mkcert = "mkcert"
    } elseif (-not (Test-Path $mkcert)) {
        Write-Host "Downloading and installing mkcert certificate tool..." -ForegroundColor Green 
        Invoke-WebRequest "" -UseBasicParsing -OutFile mkcert.exe
        if ((Get-FileHash mkcert.exe).Hash -ne "1BE92F598145F61CA67DD9F5C687DFEC17953548D013715FF54067B34D7C3246") {
            Remove-Item mkcert.exe -Force
            throw "Invalid mkcert.exe file"
    Write-Host "Generating Traefik TLS certificate..." -ForegroundColor Green
    & $mkcert -install
    & $mkcert "myapp.lovely.localhost"
catch {
    Write-Host "An error occurred while attempting to generate TLS certificate: $_" -ForegroundColor Red
finally {

# Add Windows hosts file entries

Write-Host "Adding Windows hosts file entries..." -ForegroundColor Green

Add-HostsEntry "myapp.lovely.localhost"

Write-Host "Done!" -ForegroundColor Green 

Don’t forget to update the certs_config.yaml in traefik/config/dynamic. Should look something like this:

    - certFile: C:\etc\traefik\certs\xp1cm.lovely.localhost.crt
      keyFile: C:\etc\traefik\certs\xp1cm.lovely.localhost.key
    - certFile: C:\etc\traefik\certs\xp1id.lovely.localhost.crt
      keyFile: C:\etc\traefik\certs\xp1id.lovely.localhost.key
    - certFile: C:\etc\traefik\certs\xp1cd.lovely.localhost.crt
      keyFile: C:\etc\traefik\certs\xp1cd.lovely.localhost.key
    - certFile: C:\etc\traefik\certs\myapp.lovely.localhost.crt
      keyFile: C:\etc\traefik\certs\myapp.lovely.localhost.key

In order for the SSR PROXY app to work we need to add the host name, myapp.lovely.localhost, to the “internal” docker network. We will do this by adding an alias to the CD service in the docker-compose.yml. This means we will create a network. Here is an example of a network. You can put it at the bottom of the docker-compose file:

      name: nat

Next will be to add the network to each service(including traefik), something like this:

version: "2.4"
    isolation: ${TRAEFIK_ISOLATION}
    image: ${TRAEFIK_IMAGE}
      - "--ping"
      - "--api.insecure=true"
      - "--providers.docker.endpoint=npipe:////./pipe/docker_engine"
      - "--providers.docker.exposedByDefault=false"
      - ""
      - "--entryPoints.web.address=:80"
      - "--entryPoints.websecure.address=:443"
      - "8080:80"
      - "443:443"
      - "8079:8080"
      test: ["CMD", "traefik", "healthcheck", "--ping"]
      - source: \\.\pipe\docker_engine
        target: \\.\pipe\docker_engine
        type: npipe
      - ./traefik:C:/etc/traefik
        condition: service_healthy
        condition: service_healthy
        condition: service_healthy
      - internalnet

Now to why we are doing this, we will add the alias, myapp.lovely.localhost, to the CD service:

    isolation: ${ISOLATION}
        condition: service_healthy
        condition: service_started
        condition: service_started
        condition: service_started
        condition: service_started
        condition: service_started
        condition: service_started
      Sitecore_AppSettings_instanceNameMode:define: default
      Sitecore_ConnectionStrings_Security: Data Source=mssql;Initial Catalog=Sitecore.Core;User ID=sa;Password=${SQL_SA_PASSWORD}
      Sitecore_ConnectionStrings_Web: Data Source=mssql;Initial Catalog=Sitecore.Web;User ID=sa;Password=${SQL_SA_PASSWORD}
      Sitecore_ConnectionStrings_Messaging: Data Source=mssql;Initial Catalog=Sitecore.Messaging;User ID=sa;Password=${SQL_SA_PASSWORD}
      Sitecore_ConnectionStrings_ExperienceForms: Data Source=mssql;Initial Catalog=Sitecore.ExperienceForms;User ID=sa;Password=${SQL_SA_PASSWORD}
      Sitecore_ConnectionStrings_Exm.Master: Data Source=mssql;Initial Catalog=Sitecore.Exm.master;User ID=sa;Password=${SQL_SA_PASSWORD}
      Sitecore_ConnectionStrings_Solr.Search: http://solr:8983/solr
      Sitecore_ConnectionStrings_XConnect.Collection: http://xdbcollection
      Sitecore_ConnectionStrings_Xdb.MarketingAutomation.Operations.Client: http://xdbautomation
      Sitecore_ConnectionStrings_Xdb.MarketingAutomation.Reporting.Client: http://xdbautomationrpt
      Sitecore_ConnectionStrings_Xdb.ReferenceData.Client: http://xdbrefdata
      Sitecore_ConnectionStrings_Redis.Sessions: redis:6379,ssl=False,abortConnect=False
      Sitecore_License: ${SITECORE_LICENSE}
      test: ["CMD", "powershell", "-command", "C:/Healthchecks/Healthcheck.ps1"]
      timeout: 300s
      - "traefik.enable=true"
      - ""
      - "`${CD_HOST}`)"    
            - myapp.lovely.localhost  

This means that the SSR PROXY app can now internally(using HTTP) access the myapp.lovely.localhost. We need this in order for the graphQL to work and also the fetching of dictionaries from the SSR PROXY(config.js).

Time to setup/add the SSR PROXY service. Start by adding a “build” folder next to the other folders(traefik, mssq-data and so on). Create a new folder in the build folder, let’s call it jssproxy. Now copy the node-headless-ssr-proxy from the, put it in the jssproxy folder. You should have a structure like this:
— jssproxy
—- node-headless-ssr-proxy

Next is to create a Dockerfile to the jssproxy folder. The Dockerfile will build and setup the SSR PROXY app:

FROM stefanscherer/node-windows as build

COPY ./node-headless-ssr-proxy /jss

RUN npm install

CMD [ "node", "index.js" ]

We are using an image from stefanscherer(I want a windows image with node js). Then we copy the content from the node-headless-ssr-proxy folder to the jss folder. At the end we do a nmp install and then we start the service/app.

The last part is to create a docker-compose-override.yml file. It’s the one that will call and execute the Dockerfile.

    image: myapp-jssproxy:${VERSION:-latest}
      context: ./build/jssproxy
      - "3000:3000"
      SITECORE_API_HOST: "http://myapp.lovely.localhost"
      PORT: "3000"
      SITECORE_JSS_SERVER_BUNDLE: "./dist/MyApp/server.bundle.js"
      - internalnet
      - cd
      - "traefik.enable=true"
      - "traefik.http.routers.jssproxy-secure.entrypoints=websecure"
      - "traefik.http.routers.jssproxy-secure.rule=Host(`myapp.lovely.localhost`)"
      - "traefik.http.routers.jssproxy-secure.tls=true"

Let me give you a quick explanation of what the docker.compose.override does.
The context, here we point to our newly created jssproxy folder. Notice that we’ve set the environment variable, SITECORE_API_HOST, to http://myapp.lovely.localhost. This is for the createViewBag method in the config.js. This is when it is fetching the dictionaries. And lastly in traefik we set the host name myapp.lovely.localhost with the label, traefik.http.routers.jssproxy-secure.rule=Host(myapp.lovely.localhost).

What is left is to add the built JSS app to a dist folder in the node-headless-ssr-proxy folder. You could do this manually or deploy it from your JSS app. Just open up the scjssconfig.json, update the instancePath to point to your node-headless-ssr-proxy folder. Something like this:

"instancePath": "C:\\Projects\\MyApp\\docker\\build\\jssproxy\\node-headless-ssr-proxy"

Now you can do a:

jss deploy files --clean 

It will deploy a dist folder with the app to your node-headless-ssr-proxy.

Time to test it out, open a PowerShell administrator prompt, and run the following command:

docker-compose up -d 

That’s it. Now you have a fully Sitecore 10 instance in docker with an SSR proxy running your JSS app.

That’s all for now folks 🙂

Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.