Changes to enable running Growth Book in a docker container (#13)

-  Load front-end environment variables at runtime instead of buildtime
-  Add root Dockerfile and docker-compose.yml
-  Update CI to push docker images to Docker Hub
-  Update quick start in README to use `docker-compose up -d` instead of `yarn dev`
This commit is contained in:
Jeremy Dorn
2021-06-02 17:15:31 -04:00
committed by GitHub
parent 83f8352b0b
commit 301e13a10d
23 changed files with 229 additions and 142 deletions

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
# Build and test artifacts
.git
.github
node_modules
packages/front-end/.next
packages/back-end/coverage
**/dist
**/*.log
# Docs package
packages/docs
# Secrets / private data
**/*.env.local
packages/back-end/uploads

View File

@@ -53,45 +53,39 @@ jobs:
# All changes in this push
FILE_CHANGES=$(git diff --name-only HEAD^ HEAD)
# See if any relevant back-end changes were made
HAS_BACKEND_CHANGES=$(echo "$FILE_CHANGES" | grep -qP 'yarn.lock|packages/back-end' && echo "true" || echo "false")
# See if any changes were made to the back-end
HAS_BACKEND_CHANGES=$(echo "$FILE_CHANGES" | grep -qP 'yarn.lock|back-end' && echo "true" || echo "false")
echo "::set-output name=backend::${HAS_BACKEND_CHANGES}"
- name: Configure AWS credentials
# See if any changes were made to the app (front-end or back-end)
HAS_APP_CHANGES=$(echo "$FILE_CHANGES" | grep -qP 'ci.yml|yarn.lock|package.json|Dockerfile|back-end|front-end' && echo "true" || echo "false")
echo "::set-output name=app::${HAS_APP_CHANGES}"
- name: Login to Docker Hub
if: github.ref == 'refs/heads/main' && steps.changes.outputs.app == 'true'
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build, tag, and push image to Docker Hub
if: github.ref == 'refs/heads/main' && steps.changes.outputs.app == 'true'
env:
IMAGE_TAG: ${{ github.sha }}
run: |
# Build and push the docker image
docker build -t growthbook/growthbook:latest -t growthbook/growthbook:$IMAGE_TAG .
docker push growthbook/growthbook
- name: Configure AWS credentials for Growth Book Cloud
if: github.ref == 'refs/heads/main' && steps.changes.outputs.backend == 'true'
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Login to Amazon ECR
id: login-ecr
if: github.ref == 'refs/heads/main' && steps.changes.outputs.backend == 'true'
uses: aws-actions/amazon-ecr-login@v1
- name: Build, tag, and push image to Amazon ECR
if: github.ref == 'refs/heads/main' && steps.changes.outputs.backend == 'true'
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: api
IMAGE_TAG: ${{ github.sha }}
run: |
# Build the back-end
yarn workspace back-end build
# Move into the back-end package and add the root yarn.lock
cp yarn.lock packages/back-end/yarn.lock
cd packages/back-end
# Build and push the docker image
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY
# Cleanup
rm yarn.lock
- name: Deploy to ECS
- name: Deploy Back-end to ECS for Growth Book Cloud
if: github.ref == 'refs/heads/main' && steps.changes.outputs.backend == 'true'
run:
aws ecs update-service --cluster prod-api --service prod-api --force-new-deployment --region us-east-1

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:14-alpine
WORKDIR /usr/local/src/app
# Copy only the required files
COPY . /usr/local/src/app
RUN \
# Install with dev dependencies
yarn install --frozen-lockfile --ignore-optional \
# Build the app
&& yarn build \
# Then do a clean install with only production dependencies
&& rm -rf node_modules \
&& yarn install --frozen-lockfile --production=true --ignore-optional \
# Clear the yarn cache
&& yarn cache clean
CMD ["yarn","start"]

View File

@@ -26,28 +26,40 @@ Join [our Growth Book Users Slack community](https://join.slack.com/t/growthbook
## Requirements
- NodeJS 12.x or higher
- Yarn
- Docker (plus docker-compose for running locally)
- MongoDB 3.2 or higher
- A compatible data source (Snowflake, Redshift, BigQuery, Mixpanel, Postgres, Athena, or Google Analytics)
- _(optional)_ An SMTP server for emailing invites, reset password links, etc.
- _(optional)_ Google OAuth keys (only if using Google Analytics as a data source)
Don't want to install, deploy, and maintain Growth Book on your own? Let us do it for you at https://www.growthbook.io
We also offer a hosted cloud version that's free to get started: https://app.growthbook.io
## Dev Quick Start
## Quick Start
1. Start MongoDB locally:
```sh
docker run -d -p 27017:27017 --name mongo \
-e MONGO_INITDB_ROOT_USERNAME=root \
-e MONGO_INITDB_ROOT_PASSWORD=password \
mongo
```
2. Run `yarn` to install dependencies
3. Run `yarn dev` and visit http://localhost:3000
1. Clone this repo: `git clone https://github.com/growthbook/growthbook.git && cd growthbook`
2. Start docker-compose: `docker-compose up -d`
3. Visit http://localhost:3000
If you need to change any of the default settings (e.g. to configure an email server or add Google OAuth Keys), copy `packages/back-end/.env.example` to `packages/back-end/.env.local` and edit that file as needed.
If you need to change any of the default settings (e.g. to configure an email server or add Google OAuth Keys), edit `docker-compose.yml` and add environment variables for the growthbook service.
These are all the environment variables you can set:
- **JWT_SECRET** - Auth signing key (use a long random string)
- **ENCRYPTION_KEY** - Data source credential encryption key (use a long random string)
- **APP_ORIGIN** - Used for CORS (default set to http://localhost:3000)
- **MONGODB_URI** - The MongoDB connection string
- **DISABLE_TELEMETRY** - We collect anonymous telemetry data to help us improve Growth Book. Set to "true" to disable.
- **API_HOST** - (default set to http://localhost:3100)
- Email SMTP Settings:
- **EMAIL_ENABLED** ("true" or "false")
- **EMAIL_HOST**
- **EMAIL_PORT**
- **EMAIL_HOST_USER**
- **EMAIL_HOST_PASSWORD**
- **EMAIL_USE_TLS** ("true" or "false")
- Google OAuth Settings (only if using Google Analytics as a data source)
- **GOOGLE_OAUTH_CLIENT_ID**
- **GOOGLE_OAUTH_CLIENT_SECRET**
View the full developer docs at https://docs.growthbook.io

16
docker-compose.yml Normal file
View File

@@ -0,0 +1,16 @@
version: "3"
services:
mongo:
image: "mongo:latest"
environment:
- MONGO_INITDB_ROOT_USERNAME=root
- MONGO_INITDB_ROOT_PASSWORD=password
growthbook:
image: "growthbook/growthbook:latest"
ports:
- "3000:3000"
- "3100:3100"
depends_on:
- mongo
environment:
- MONGODB_URI=mongodb://root:password@mongo:27017/

View File

@@ -8,19 +8,15 @@
"type-check": "wsrun -m type-check",
"test": "wsrun -m test",
"dev": "wsrun -p '*-end' -m dev",
"dev:docs": "yarn workspace docs dev",
"build": "wsrun -m build",
"build:back": "yarn workspace back-end build",
"build:front": "yarn workspace front-end build",
"build:docs": "yarn workspace docs build",
"start:back": "yarn workspace back-end start",
"start:front": "yarn workspace front-end start",
"start:docs": "yarn workspace docs start"
"build": "wsrun -p '*-end' -m build",
"start": "wsrun -p '*-end' -m start"
},
"workspaces": [
"packages/*"
],
"dependencies": {},
"dependencies": {
"wsrun": "^5.2.4"
},
"devDependencies": {
"@next/eslint-plugin-next": "^10.2.0",
"@types/eslint": "^6.1.1",
@@ -33,8 +29,7 @@
"husky": "^4.2.5",
"lint-staged": "^10.2.7",
"prettier": "^2.2.1",
"typescript": "^4.2.4",
"wsrun": "^5.2.4"
"typescript": "^4.2.4"
},
"husky": {
"hooks": {

View File

@@ -1,12 +0,0 @@
FROM node:alpine
WORKDIR /usr/local/src/app
COPY ./dist /usr/local/src/app/dist
COPY ./package.json /usr/local/src/app/package.json
COPY ./yarn.lock /usr/local/src/app/yarn.lock
RUN yarn install --frozen-lockfile --production=true --ignore-optional \
&& rm -rf /usr/local/share/.cache/yarn
CMD ["yarn","start"]

View File

@@ -5,7 +5,7 @@
"scripts": {
"copy-templates": "mkdir -p dist/templates && cp -r src/templates/* dist/templates",
"dev": "yarn copy-templates && ts-node-dev src/server.ts",
"build": "tsc && rm -rf dist/types && mv dist/src/* dist/ && rm -rf dist/src && yarn copy-templates",
"build": "rm -rf dist && tsc && rm -rf dist/types && mv dist/src/* dist/ && rm -rf dist/src && yarn copy-templates",
"start": "node dist/server.js",
"test": "jest --forceExit --coverage --verbose --detectOpenHandles",
"type-check": "tsc --pretty --noEmit",

View File

@@ -1 +1 @@
NEXT_PUBLIC_API_HOST=http://localhost:3100
API_HOST=http://localhost:3100

View File

@@ -1,8 +1,6 @@
import Auth from "../components/Auth/Auth";
import { AuthSource } from "../services/auth";
import { getApiHost } from "../services/utils";
const apiHost = getApiHost();
import { getApiHost } from "../services/env";
let token: string;
let createdAt: number;
@@ -14,7 +12,7 @@ async function refreshToken(): Promise<void> {
return;
}
const res = await fetch(apiHost + "/auth/refresh", {
const res = await fetch(getApiHost() + "/auth/refresh", {
method: "POST",
credentials: "include",
});
@@ -67,7 +65,7 @@ const localAuthSource: AuthSource = {
logout: async () => {
token = "";
createdAt = 0;
await fetch(apiHost + "/auth/logout", {
await fetch(getApiHost() + "/auth/logout", {
method: "POST",
credentials: "include",
});

View File

@@ -1,11 +1,9 @@
import { ReactElement, useState } from "react";
import useForm from "../../hooks/useForm";
import track from "../../services/track";
import { getApiHost } from "../../services/utils";
import { getApiHost } from "../../services/env";
import Modal from "../Modal";
const apiHost = getApiHost();
export default function Auth({
onSuccess,
}: {
@@ -28,7 +26,7 @@ export default function Auth({
state === "forgotSuccess"
? undefined
: async () => {
const res = await fetch(apiHost + "/auth/" + state, {
const res = await fetch(getApiHost() + "/auth/" + state, {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@@ -6,7 +6,7 @@ import { useRouter } from "next/router";
import clsx from "clsx";
import styles from "./SidebarLink.module.scss";
import { FiChevronDown } from "react-icons/fi";
import { isCloud } from "../../services/utils";
import { isCloud } from "../../services/env";
export type SidebarLinkProps = {
name: string;
@@ -79,6 +79,12 @@ const SidebarLink: FC<SidebarLinkProps> = (props) => {
if (l.superAdmin && !admin) return null;
if (l.settingsPermission && !permissions.organizationSettings)
return null;
if (l.cloudOnly && !isCloud()) {
return null;
}
if (l.selfHostedOnly && isCloud()) {
return null;
}
return (
<li

View File

@@ -18,7 +18,7 @@ import clsx from "clsx";
import styles from "./TopNav.module.scss";
import { useRouter } from "next/router";
import ChangePasswordModal from "../Auth/ChangePasswordModal";
import { isCloud } from "../../services/utils";
import { isCloud } from "../../services/env";
const TopNav: FC<{
toggleLeftMenu?: () => void;

View File

@@ -2,7 +2,7 @@ import { ApiKeyInterface } from "back-end/types/apikey";
import { useEffect } from "react";
import { useState } from "react";
import { FaKey, FaPencilAlt } from "react-icons/fa";
import { getApiHost, isCloud } from "../../services/utils";
import { getApiHost, isCloud } from "../../services/env";
import Code from "../Code";
import ApiKeysModal from "./ApiKeysModal";

View File

@@ -8,6 +8,9 @@ import Head from "next/head";
import { DefinitionsProvider } from "../services/DefinitionsContext";
import { useEffect } from "react";
import track from "../services/track";
import { initEnv } from "../services/env";
import { useState } from "react";
import LoadingOverlay from "../components/LoadingOverlay";
type ModAppProps = AppProps & {
Component: { noOrganization?: boolean; preAuth?: boolean };
};
@@ -17,6 +20,9 @@ function App({
pageProps,
router,
}: ModAppProps): React.ReactElement {
const [ready, setReady] = useState(false);
const [error, setError] = useState("");
// hacky:
const parts = router.route.substr(1).split("/");
@@ -24,32 +30,53 @@ function App({
const preAuth = Component.preAuth || false;
useEffect(() => {
track("App Load");
initEnv()
.then(() => {
setReady(true);
})
.catch((e) => {
setError(e.message);
});
}, []);
useEffect(() => {
if (!ready) return;
track("App Load");
}, [ready]);
return (
<>
<Head>
<title>Growth Book</title>
<meta name="robots" content="noindex, nofollow" />
</Head>
<AuthProvider>
<ProtectedPage
organizationRequired={organizationRequired}
preAuth={preAuth}
>
{organizationRequired && !preAuth ? (
<DefinitionsProvider>
<Layout />
<main className={`main ${parts[0]}`}>
<Component {...pageProps} />
</main>
</DefinitionsProvider>
) : (
<Component {...pageProps} />
)}
</ProtectedPage>
</AuthProvider>
{ready ? (
<AuthProvider>
<ProtectedPage
organizationRequired={organizationRequired}
preAuth={preAuth}
>
{organizationRequired && !preAuth ? (
<DefinitionsProvider>
<Layout />
<main className={`main ${parts[0]}`}>
<Component {...pageProps} />
</main>
</DefinitionsProvider>
) : (
<Component {...pageProps} />
)}
</ProtectedPage>
</AuthProvider>
) : error ? (
<div className="container mt-3">
<div className="alert alert-danger">
Error Initializing Growth Book: {error}
</div>
</div>
) : (
<LoadingOverlay />
)}
</>
);
}

View File

@@ -0,0 +1,17 @@
import { NextApiRequest, NextApiResponse } from "next";
// Get env variables at runtime on the front-end while still using SSG
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const { API_HOST, IS_CLOUD, DISABLE_TELEMETRY } = process.env;
res.status(200).json({
apiHost: API_HOST || "http://localhost:3100",
cloud: !!IS_CLOUD,
telemetry:
DISABLE_TELEMETRY === "debug"
? "debug"
: DISABLE_TELEMETRY
? "disable"
: "enable",
});
}

View File

@@ -3,9 +3,7 @@ import { useRouter } from "next/router";
import { ReactElement, useEffect, useState } from "react";
import LoadingOverlay from "../components/LoadingOverlay";
import Modal from "../components/Modal";
import { getApiHost, isCloud } from "../services/utils";
const apiHost = getApiHost();
import { getApiHost, isCloud } from "../services/env";
export default function ResetPasswordPage(): ReactElement {
const router = useRouter();
@@ -25,7 +23,7 @@ export default function ResetPasswordPage(): ReactElement {
}
// Check if token is valid
fetch(apiHost + "/auth/reset/" + token)
fetch(getApiHost() + "/auth/reset/" + token)
.then((res) => res.json())
.then((json: { status: number; message?: string; email?: string }) => {
if (json.status > 200) {
@@ -57,7 +55,7 @@ export default function ResetPasswordPage(): ReactElement {
success || error || loading
? undefined
: async () => {
const res = await fetch(apiHost + "/auth/reset/" + token, {
const res = await fetch(getApiHost() + "/auth/reset/" + token, {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@@ -5,7 +5,7 @@ import { SettingsApiResponse } from ".";
import LoadingOverlay from "../../components/LoadingOverlay";
import SubscriptionInfo from "../../components/Settings/SubscriptionInfo";
import useApi from "../../hooks/useApi";
import { isCloud } from "../../services/utils";
import { isCloud } from "../../services/env";
const BillingPage: FC = () => {
const { data, error } = useApi<SettingsApiResponse>(`/organization`);

View File

@@ -5,9 +5,7 @@ import auth0AuthSource from "../authSources/auth0AuthSource";
import localAuthSource from "../authSources/localAuthSource";
import { OrganizationInterface } from "back-end/types/organization";
import Modal from "../components/Modal";
import { getApiHost, isCloud } from "./utils";
const apiHost = getApiHost();
import { getApiHost, isCloud } from "./env";
export type MemberRole = "collaborator" | "designer" | "developer" | "admin";
@@ -90,8 +88,6 @@ export interface AuthSource {
getJWT: () => Promise<string>;
}
const authSource = isCloud() ? auth0AuthSource : localAuthSource;
export const AuthProvider: React.FC = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
@@ -106,6 +102,8 @@ export const AuthProvider: React.FC = ({ children }) => {
const router = useRouter();
const initialOrgId = router.query.org ? router.query.org + "" : null;
const authSource = isCloud() ? auth0AuthSource : localAuthSource;
useEffect(() => {
authSource
.init(router)
@@ -148,12 +146,8 @@ export const AuthProvider: React.FC = ({ children }) => {
>
<h3>Error Reaching API</h3>
<p>
Could not communicate with the Growth Book API at{" "}
<code>{getApiHost()}</code>.
</p>
<p>
If you just started the server with <code>yarn dev</code>, wait a
minute for the back-end to fully initialize and try again.
Could not reach the Growth Book API at <code>{getApiHost()}</code>. Is
it running?
</p>
</Modal>
);
@@ -195,7 +189,7 @@ export const AuthProvider: React.FC = ({ children }) => {
init.headers["X-Organization"] = orgId;
}
const response = await fetch(apiHost + url, init);
const response = await fetch(getApiHost() + url, init);
const responseData = await response.json();

View File

@@ -0,0 +1,28 @@
const env: {
telemetry: "debug" | "enable" | "disable";
cloud: boolean;
apiHost: string;
} = {
telemetry: "enable",
cloud: false,
apiHost: "",
};
export async function initEnv() {
const res = await fetch("/api/init");
const json = await res.json();
Object.assign(env, json);
}
export function getApiHost(): string {
return env.apiHost;
}
export function isCloud(): boolean {
return env.cloud;
}
export function isTelemetryEnabled() {
return env.telemetry === "enable";
}
export function inTelemetryDebugMode(): boolean {
return env.telemetry === "debug";
}

View File

@@ -1,7 +1,5 @@
import { ApiCallType } from "./auth";
import { getApiHost } from "./utils";
const apiHost = getApiHost();
import { getApiHost } from "./env";
export async function uploadFile(
apiCall: ApiCallType<{ uploadURL: string; fileURL: string }>,
@@ -13,7 +11,7 @@ export async function uploadFile(
});
const res = await fetch(
uploadURL.match(/^\//) ? apiHost + uploadURL : uploadURL,
uploadURL.match(/^\//) ? getApiHost() + uploadURL : uploadURL,
{
method: "PUT",
headers: {
@@ -28,6 +26,6 @@ export async function uploadFile(
}
return {
fileURL: fileURL.match(/^\//) ? apiHost + fileURL : fileURL,
fileURL: fileURL.match(/^\//) ? getApiHost() + fileURL : fileURL,
};
}

View File

@@ -6,27 +6,18 @@ Track anonymous usage statistics
- For example, if people start creating a metric and then
abandon the form, that tells us the UI needs improvement.
- You can disable this tracking completely by setting
NEXT_PUBLIC_DISABLE_TELEMETRY=1 in your env.
- To console.log the telemetry data instead of sending to us,
you can set NEXT_PUBLIC_TELEMETRY_DEBUG=1 in your env.
DISABLE_TELEMETRY=1 in your env.
*/
import { jitsuClient, JitsuClient } from "@jitsu/sdk-js";
import { isCloud } from "./utils";
export function isTelemetryEnabled() {
return (
!process.env.NEXT_PUBLIC_DISABLE_TELEMETRY &&
!process.env.NEXT_PUBLIC_TELEMETRY_DEBUG
);
}
import { inTelemetryDebugMode, isCloud, isTelemetryEnabled } from "./env";
let jitsu: JitsuClient;
export default function track(
event: string,
props: Record<string, unknown> = {}
): void {
if (process.env.NEXT_PUBLIC_TELEMETRY_DEBUG) {
if (inTelemetryDebugMode()) {
console.log("Telemetry Event - ", event, props);
}
if (!isTelemetryEnabled()) return;

View File

@@ -22,10 +22,3 @@ export function getEvenSplit(n: number) {
return weights;
}
export function getApiHost(): string {
return process.env.NEXT_PUBLIC_API_HOST || "http://localhost:3100";
}
export function isCloud(): boolean {
return !!process.env.NEXT_PUBLIC_IS_CLOUD;
}