Next.js: Immutable, multi-environment Docker deployments

Created on 24 Mar 2017  路  26Comments  路  Source: vercel/next.js

Update by Next.js maintainers

Since this issue was first posted Next.js has added universal webpack support, meaning build-time environment variables can now be defined in next.config.js using the webpack function and webpack.DefinePlugin.

For runtime environment variables the publicRuntimeConfig config key was introduced. You can read documentation here: https://github.com/zeit/next.js#exposing-configuration-to-the-server--client-side


Original message

Hello! I just finished porting a small universal app from React Server to next.js. Yay! Loving it so far. 馃帀

One feature I'm missing from React Server is the ability to pass environment-specific config (e.g. a REST API endpoint) into the app at start time. Keeping environment configuration out of the build artifacts means I can have a single Docker image which can be tested on a staging cluster and then deployed to production, without having to rebuild.

React Server's way of doing this is described here, (scroll down to _If you want to pass per-environment configuration settings to YOUR application_). It's not the most elegant solution but it does solve this problem.

The with-universal-configuration example suggests using the transform-define babel plugin to inject configuration into the app, but this freezes config at build time, producing environment-specific artifacts.

Is there a mechanism for conveying environment variables into a next.js app at start / run time, to both client and server? If not, with some initial guidance I could probably look into adding this feature to next.js.

Thanks!

Most helpful comment

Thanks for your reply! I found a pretty good solution using the document API.

For posterity here's how I did it, pretty straightforward

  1. Create an env.js file with config for all environments in it:
const envConfig =  {
  prod: {
    GRAPHQL_ENDPOINT: "https://prod-host/graphql"
  },
  qa: {
    GRAPHQL_ENDPOINT: "https://qa-host/graphql"
  },
  dev: {
    GRAPHQL_ENDPOINT: "https://dev-host/graphql"
  }
};

const currentEnv = (process.browser ? window.ENV : process.env.ENV) || 'dev';

export default envConfig[currentEnv];
  1. Create a pages/_document.js that sets window.ENV:
import Document, { Head, Main, NextScript } from 'next/document'
import flush from 'styled-jsx/server'

export default class MyDocument extends Document {
  static getInitialProps ({ renderPage }) {
    const {html, head} = renderPage();
    const styles = flush();
    return { html, head, styles };
  }

  render () {
    const script = `window.ENV = '${process.env.ENV || 'dev'}';`;
    return (
      <html>
      <Head>
        <script dangerouslySetInnerHTML={{__html: script}}/>
      </Head>
      <body>
      <Main />
      <NextScript />
      </body>
      </html>
    )
  }
}
  1. Use env vars everywhere
import env from '../env';

console.log(env.GRAPHQL_ENDPOINT);

seems to work.

All 26 comments

Using something like transform-define to do pass config in the build time is our recommended method.

If you want to do it on the server, try to pass those configs via getInitialProps or use custom document API to set it to the window. So, you can access it from the client.

But we won't be providing a built-in method to do that.

Thanks for your reply! I found a pretty good solution using the document API.

For posterity here's how I did it, pretty straightforward

  1. Create an env.js file with config for all environments in it:
const envConfig =  {
  prod: {
    GRAPHQL_ENDPOINT: "https://prod-host/graphql"
  },
  qa: {
    GRAPHQL_ENDPOINT: "https://qa-host/graphql"
  },
  dev: {
    GRAPHQL_ENDPOINT: "https://dev-host/graphql"
  }
};

const currentEnv = (process.browser ? window.ENV : process.env.ENV) || 'dev';

export default envConfig[currentEnv];
  1. Create a pages/_document.js that sets window.ENV:
import Document, { Head, Main, NextScript } from 'next/document'
import flush from 'styled-jsx/server'

export default class MyDocument extends Document {
  static getInitialProps ({ renderPage }) {
    const {html, head} = renderPage();
    const styles = flush();
    return { html, head, styles };
  }

  render () {
    const script = `window.ENV = '${process.env.ENV || 'dev'}';`;
    return (
      <html>
      <Head>
        <script dangerouslySetInnerHTML={{__html: script}}/>
      </Head>
      <body>
      <Main />
      <NextScript />
      </body>
      </html>
    )
  }
}
  1. Use env vars everywhere
import env from '../env';

console.log(env.GRAPHQL_ENDPOINT);

seems to work.

Would be great to update the with-universal-configuration example with this 馃檪

const script = `window.ENV = '${process.env.ENV || 'dev'}';`;

@keeth I think this can be dangerous. Sooner or later someone will put server only secret like DB password there, and it will be auto-propagated to a client.

env-config is more granular. PUT_IT_TO_CLIENT_AND_YOU_WILL_BE_FIRED_DB_PASSWORD is better. That's problem (solvable) of isomorphic design where code is shared.

Maybe even better solution. Use process.env.SECRET directly in the server only code.

@steida ya it seems smart to keep your isomorphic config vars separate from server-only vars. There are lots of good solutions for server-only config (like env-config as you mentioned). Although I find personally that I don't need to keep secrets in my universal apps at all, I try to keep the UI layer very simple and do the heavy lifting elsewhere (e.g. behind a GraphQL endpoint in a separate service).

The example used here will rewrite the env variable every time _document.js renders, meaning that it will default to 'dev' whenever _document is rendered client-side.

@jth0024 ya now that you mention it, that's what I'd expect to happen! but for some reason when I do client-side navigation the window.ENV variable does not get reset. I see a possible explanation here:

https://github.com/zeit/next.js#custom-document

React-components outside of Main will not be initialised by the browser

@keeth That's definitely interesting. Perhaps I had a different issue that caused that behavior. Either way, did you consider using babel-plugin-transform-define and environment presets in the .babelrc to ensure process.env.NODE_ENV is defined correctly on client and server? This allows the same universal configuration as your example would, but it feels a little less hacky to me because you can just use process.env.NODE_ENV anywhere. https://www.npmjs.com/package/babel-plugin-transform-define

@jth0024 see the initial post. This issue is about doing immutable deployments, where the same artifacts are deployed in staging and prod, in the spirit of Docker and 12 Factor. Using transform-define means locking the config to a particular environment at build-time, which goes against these principles. The beauty of immutable deployments is that once you have QA'd your app, you deploy the exact same artifacts (minified js/css, etc) to prod without rebuilding anything, so you avoid any bugs that might be injected by the build process.

@keeth did you commit env.js to your repo? One of our devOps wants to override env config via CLI. Ultimately I'd like an immutable deploy we can override via CLI and I'm torn between your solution and dotenv. 馃

@simplynutty yes I do commit env.js but it's not necessary to do so. also you could have a base env in source control and a local env file that isn't.

@simplynutty the main limitation of the above technique is that it only passes the env id (dev,prod,etc) through to the client, so it assumes that the config vars themselves are baked into the JS on both server and client. if you wanted more control over individual vars, or to use something like dotenv, you'd probably need to pass the whole config to the client (using the script tag in _document). that might ultimately be a better, more flexible solution. you'd just need to decide which env vars get propagated this way. maybe you could prefix env vars you care about with NEXT_ or something.

@keeth I found a decent solution to this problem that allows you to control individual variables at runtime and avoids the specific limitations you mentioned.

__document.js

import h from 'react-hyperscript';
import DefaultDocument, { Main, NextScript } from 'next/document';
import config from '../config';

export default class Document extends DefaultDocument {
  static getInitialProps({ renderPage }) {
    const { chunks, errorHtml, head, html } = renderPage();
    return { chunks, errorHtml, head, html };
  }

  render() {
    return h('html', {}, [
      h('body', [
        h('script', {
          dangerouslySetInnerHTML: {
            __html: `${config.GLOBAL_VAR_NAME} = ${JSON.stringify(config)};`,
          },
        h(Main),
        h(NextScript),
      ]),
    ]);
  }
} 

config.js

const _ = require('lodash');

function getConfig() {
  const isClient = typeof window !== 'undefined';
  const globalVarName = '__NEXT_APP_CONFIG__';

  if (isClient) return window[globalVarName];

  const config = {
    // Common values are either:
    //    1) fixed for all environments
    //    2) set at runtime via process args for each specific environment
    common: {
      GLOBAL_VAR_NAME: globalVarName,
      HOST: _.get(process, 'env.HOST', 'http://localhost:3000'),
    },

    // Development values are used when the app is run locally via "next"
    development: {
      LOGGING_ENABLED: true,
    },

    // Production values are used for any deployment environment (qa, staging, prod, etc.)
    // when the app is built via "next build" and run via "next start".
    production: {
      LOGGING_ENABLED: false,
    },
  };

  return _.extend({}, config.common, config[process.env.NODE_ENV]);
}

module.exports = getConfig();

How about define it in next.config.js with webpack.DefinePlugin?
js config.plugins.push( new webpack.DefinePlugin({ 'process.env.ENV': `"${ NODE_ENV }"`, }), );

That would be awesome @davidnguyen179 but I've not had any success getting it working on both client and server. I read this is not possible in some other issue that I can't find now.

Are there any other ideas worth trying? The caveat on https://github.com/zeit/next.js/tree/canary/examples/with-universal-configuration is really nasty.

https://github.com/zeit/next.js/pull/3578 will make it possible to use webpack.DefinePlugin 馃憤

Webpack sets build time variables doesn鈥檛 it? That would require a rebuild between staging and production.

To build staging as production"

https://medium.com/@nndung179/nextjs-multiple-environment-builds-e8b2ccb11c04

To make ENV the same between Server & Client. I suggest the way as follow:

_document.js
I added window.ENV to the response from server to client

render() {
    const env = `window.ENV = '${process.env.ENV || 'development'}';`;
    return (
      <html lang="en">
        <Head>
           <title>page</title>
        </Head>
        <body>
          <div className="root"><Main /></div>
          <script dangerouslySetInnerHTML={{ __html: env }} />
          <NextScript />
        </body>
      </html>
    );
  }

in the config.js load resource with ENV

const env = (process.browser ? window.ENV : process.env.ENV) || 'development';

const config = {
  development: {
    graphql: {
      uri: 'http://localhost:4000/graphql/',
    },
  },
  staging: {
    graphql: {
      uri: 'https://staging.api.com',
    },
  },
  production: {
    graphql: {
      uri: 'https://production.api.com',
    },
  },
}[env];

export default config;

It worked well for me

@keeth
Thank you, thank you, thank you, thank you!

Argh. I spoke too soon. Now I'm trying to launch with NODE_ENV=test, but it's not working.
The express server bit reports (initially) that NODE_ENV == 'test', but then the client reports 'development'.
I've removed any explicit setting of NODE_ENV to 'development' from my code, so I'm guessing this is being introduced by babel somewhere, or maybe cached. I tried deleting the .next directory but that didn't fix it.
Would you be able to explain how the example you posted works, @timneutkens ? I can't see where window.__ENV__ is coming from.

@biot023 it's coming from pages/_document.js. The _document.js file is only rendered on the server side. So we use it to expose the environment variables defined there. It's what we use for https://zeit.co 馃憤

@timneutkens
Ah, okay, thanks for that.
Unfortunately, in my example, window.ENV gets set to "development", whereas the server is started with NODE_ENV="test".
However, I've had a bit of a rethink and decided that for a really small inconvenience my front-end dev and test environments don't really need to be differently configured, so I'll just stick to assuming the environment will be either "development" or "production".
I'll revisit this issue maybe when I've got rather more Next.js experience under my belt! :)
Thanks again!

@biot023 you can bypass this issue by sending back the environment in the getInitialProps return data. This way the client gets its env from the server

This is a new feature that will be introduced with Next 5.1: https://github.com/zeit/next.js#exposing-configuration-to-the-server--client-side

You can already use it on next@canary.

Was this page helpful?
0 / 5 - 0 ratings