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 https://doc.sitecore.com/developers/100/ 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: https://sweden.se/culture-traditions/lucia/
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 later8. 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') { console.error( 'The `graphQLEndpointPath` and/or `layoutServiceHost` configurations were not defined. You may need to run `jss setup`.' ); process.exit(1); } // 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') { console.error( 'The `graphQLEndpointPath` and/or `layoutServiceHost` configurations were not defined. You may need to run `jss setup`.' ); process.exit(1); } // 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. addGraphQLConfig(config); 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') { console.error( 'The `graphQLEndpointPath` and/or `layoutServiceHost` configurations were not defined. You may need to run `jss setup`.' ); process.exit(1); } // 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 { jss }
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 { rendered(fieldRendererParameters:"$AlwaysIncludeServerUrl=false") }
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="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/" xmlns:role="http://www.sitecore.net/xmlconfig/role/"> <sitecore role:require="ContentDelivery"> <layoutService> <configurations> <config name="jss"> <rendering> <renderingContentsResolver> <IncludeServerUrlInMediaUrls>false</IncludeServerUrlInMediaUrls> </renderingContentsResolver> </rendering> </config> </configurations> </layoutService> </sitecore> </configuration>
We will also add our graphQL config. Something like this:
<?xml version="1.0"?> <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/" xmlns:role="http://www.sitecore.net/xmlconfig/role/"> <sitecore role:require="ContentManagement or ContentDelivery"> <api> <!-- 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. --> <GraphQL> <endpoints> <MyAppGraphQLEndpoint url="/api/myapp" type="Sitecore.Services.GraphQL.Hosting.GraphQLEndpoint, Sitecore.Services.GraphQL.NetFxHost" resolve="true"> <url>$(url)</url> <enabled>true</enabled> <enableSubscriptions>true</enableSubscriptions> <!-- 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"> <database>context</database> <paths hint="list:AddIncludedPath"> <templates>/sitecore/templates/PATH TO APP SPECIFIC TEMPLATES</templates> </paths> <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="__*" /> </exclusions> </fieldFilter> </templates> <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" /> </queries> <fieldTypeMapping ref="/sitecore/api/GraphQL/defaults/content/fieldTypeMappings/standardTypeMapping" /> </content> </schema> <!-- 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" /> </extenders> <!-- 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> </cache> </web> </endpoints> </GraphQL> </api> </sitecore> </configuration>
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 asp.net core example project – https://github.com/Sitecore/Helix.Examples/tree/master/examples/helix-basic-aspnetcore.
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 "https://github.com/FiloSottile/mkcert/releases/download/v1.4.1/mkcert-v1.4.1-windows-amd64.exe" -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 { Pop-Location } ################################ # 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:
tls: certificates: - 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:
networks: internalnet: external: name: nat
Next will be to add the network to each service(including traefik), something like this:
version: "2.4" services: traefik: isolation: ${TRAEFIK_ISOLATION} image: ${TRAEFIK_IMAGE} command: - "--ping" - "--api.insecure=true" - "--providers.docker.endpoint=npipe:////./pipe/docker_engine" - "--providers.docker.exposedByDefault=false" - "--providers.file.directory=C:/etc/traefik/config/dynamic" - "--entryPoints.web.address=:80" - "--entryPoints.websecure.address=:443" ports: - "8080:80" - "443:443" - "8079:8080" healthcheck: test: ["CMD", "traefik", "healthcheck", "--ping"] volumes: - source: \\.\pipe\docker_engine target: \\.\pipe\docker_engine type: npipe - ./traefik:C:/etc/traefik depends_on: id: condition: service_healthy cd: condition: service_healthy cm: condition: service_healthy networks: - internalnet
Now to why we are doing this, we will add the alias, myapp.lovely.localhost, to the CD service:
cd: isolation: ${ISOLATION} image: ${SITECORE_DOCKER_REGISTRY}sitecore-xp1-cd:${SITECORE_VERSION} depends_on: mssql: condition: service_healthy solr: condition: service_started redis: condition: service_started xdbcollection: condition: service_started xdbautomation: condition: service_started xdbautomationrpt: condition: service_started xdbrefdata: condition: service_started environment: 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} healthcheck: test: ["CMD", "powershell", "-command", "C:/Healthchecks/Healthcheck.ps1"] timeout: 300s labels: - "traefik.enable=true" - "traefik.http.routers.cd.entrypoints=web" - "traefik.http.routers.cd.rule=Host(`${CD_HOST}`)" networks: internalnet: aliases: - 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 https://github.com/Sitecore/jss/tree/master/samples/node-headless-ssr-proxy, put it in the jssproxy folder. You should have a structure like this:
build
— 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 WORKDIR /jss 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.
jssproxy: image: myapp-jssproxy:${VERSION:-latest} build: context: ./build/jssproxy ports: - "3000:3000" environment: SITECORE_API_HOST: "http://myapp.lovely.localhost" PORT: "3000" SITECORE_API_KEY: "YOUR API KEY TO THE APP HERE" SITECORE_APP_NAME: "MyApp" SITECORE_JSS_SERVER_BUNDLE: "./dist/MyApp/server.bundle.js" networks: - internalnet depends_on: - cd labels: - "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 🙂
2 thoughts on “Running Sitecore JSS with Node Headless SSR Proxy in Sitecore 10, all in Docker – This is the Way”