├── .dockerignore ├── .gitignore ├── .storybook ├── config.js └── webpack.config.js ├── Dockerfile ├── README.md ├── app.code-workspace ├── components ├── Button.stories.tsx └── Button.tsx ├── docker-compose.yml ├── jest.config.js ├── manifests ├── deployment-template.yml └── service-template.yml ├── next-env.d.ts ├── package-lock.json ├── package.json ├── pages └── index.tsx ├── scripts ├── build-image.sh ├── deploy-image.sh ├── push-image.sh ├── run-continuous-integration.sh ├── run-external-tests.sh └── run-internal-tests.sh ├── tsconfig.json └── utility ├── fizzBuzz.test.ts └── fizzBuzz.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | Dockerfile* 4 | docker-compose* 5 | .dockerignore 6 | .git 7 | .gitignore 8 | README.md 9 | LICENSE 10 | .vscode 11 | .next 12 | *.swp 13 | /scripts 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | .DS_Store 4 | *.swp 5 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react' 2 | 3 | const req = require.context('../components', true, /.stories.tsx$/) 4 | function loadStories() { 5 | req.keys().forEach(filename => req(filename)) 6 | } 7 | 8 | configure(loadStories, module) 9 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin') 2 | 3 | module.exports = ({ config, mode }) => { 4 | config.module.rules.push({ 5 | test: /\.(ts|tsx)$/, 6 | loader: require.resolve('babel-loader'), 7 | options: { 8 | presets: [require.resolve('babel-preset-react-app')], 9 | }, 10 | }) 11 | 12 | config.resolve.extensions.push('.ts', '.tsx') 13 | 14 | config.plugins.push( 15 | new ForkTsCheckerWebpackPlugin({ 16 | async: false, 17 | checkSyntacticErrors: true, 18 | formatter: require('react-dev-utils/typescriptFormatter'), 19 | }) 20 | ) 21 | return config 22 | } 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Test 2 | FROM node:12.8-alpine as test-target 3 | ENV NODE_ENV=development 4 | ENV PATH $PATH:/usr/src/app/node_modules/.bin 5 | 6 | WORKDIR /usr/src/app 7 | 8 | COPY package*.json ./ 9 | 10 | # CI and release builds should use npm ci to fully respect the lockfile. 11 | # Local development may use npm install for opportunistic package updates. 12 | ARG npm_install_command=ci 13 | RUN npm $npm_install_command 14 | 15 | COPY . . 16 | 17 | # Build 18 | FROM test-target as build-target 19 | ENV NODE_ENV=production 20 | 21 | # Use build tools, installed as development packages, to produce a release build. 22 | RUN npm run build 23 | 24 | # Reduce installed packages to production-only. 25 | RUN npm prune --production 26 | 27 | # Archive 28 | FROM node:12.8-alpine as archive-target 29 | ENV NODE_ENV=production 30 | ENV PATH $PATH:/usr/src/app/node_modules/.bin 31 | 32 | WORKDIR /usr/src/app 33 | 34 | # Include only the release build and production packages. 35 | COPY --from=build-target /usr/src/app/node_modules node_modules 36 | COPY --from=build-target /usr/src/app/.next .next 37 | 38 | CMD ["next", "start"] 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | _This repo is years out of date and not maintained. Don't assume you need this stack of parts to build and ship a website anyway. How about [starting small](https://www.gov.uk/service-manual/technology/using-progressive-enhancement)?_ 2 | 3 | # Next.js in Docker Example 4 | 5 | This repository demonstrates a Next.js web application that uses Docker for both deployment and a development environment. 6 | 7 | Check my commit messages to see the tutorial articles and documentation I followed as I built this up piece by piece. 8 | 9 | ## Progress 10 | 11 | - [x] Node app in Docker 12 | - [x] Debuggable in VS Code 13 | - [x] Development and Release container variants 14 | - [x] Next.js 15 | - [x] TypeScript 16 | - [x] Allow launching Storybook at the test stage 17 | - [x] Allow running CI tools at the test stage 18 | - [x] Allow debugging individual tests 19 | - [x] Use multi-stage Docker builds for compact release artifacts 20 | - [x] Use layer caching for efficient builds 21 | - [x] Deploy to Kubernetes with kubectl 22 | - [x] Demonstrate a complete CI workflow 23 | - [x] Allow running CI tools against a built release artifact to validate it 24 | 25 | ## How this setup uses Docker 26 | 27 | - `Dockerfile` describes a multi-stage build. 28 | - The test stage includes a copy of the checked out workspace with all packages installed. The builds for the CI and debug services stop here. 29 | - The build stage continues by removing development packages and preparing a release build. 30 | - The archive stage starts fresh and copies in just what's needed at runtime for a small release container. 31 | - `docker-compose.yml` prepares a development environment including a debuggable web server and a Storybook server. 32 | - `scripts/run-continuous-integration.sh` and its helper scripts perform a complete CI workflow, including building the application in Docker containers, testing it, pushing it to a container registry, and deploying it to Kubernetes. 33 | 34 | ## How to use this setup 35 | 36 | - Clone the repo. 37 | - Install Docker and VS Code. 38 | - Open `app.code-workspace`. VS Code will recommend a Docker plugin if you don't have it, so install that. 39 | - To develop in Docker, right-click `docker-compose.yml` and select Compose Up. 40 | - Canonically, `docker-compose up` will start debug mode too. Add `--build` if you have made changes since last time. 41 | - In VS Code's activity bar, click the Docker icon to view running containers. 42 | - Visit localhost:3000 to view the site. 43 | - Visit localhost:6006 to view Storybook. It may take a moment for the Storybook server to start after docker-compose launches its container. 44 | - Save a code file to hot-reload the browser. 45 | - Select Start Debugging from the Debug menu to attach the debugger. Then click in VS Code's gutter to set breakpoints. In this example, only the web server is debuggable, not Storybook. 46 | - To stop, right-click again and select Compose Down, or use `docker-compose down`. 47 | - In addition to the Docker debugger attachment launcher, VS Code launchers also exist for debugging locally, including the web server, all unit tests, and a single unit test file. 48 | - To build, test, and deploy in a continuous integration environment run `./scripts/run-continuous-integration.sh --image --version `. 49 | - This workflow assumes `docker` is logged into whatever registry you attempt to use for storage and that `kubectl` is configured with a context where you can perform a deployment. 50 | -------------------------------------------------------------------------------- /app.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "launch": { 3 | "configurations": [ 4 | { 5 | "name": "Docker: Attach to Node", 6 | "type": "node", 7 | "request": "attach", 8 | "address": "localhost", 9 | "localRoot": "${workspaceFolder}", 10 | "remoteRoot": "/usr/src/app", 11 | "protocol": "inspector", 12 | "restart": true 13 | }, 14 | { 15 | "name": "Server (Local)", 16 | "type": "node", 17 | "request": "launch", 18 | "program": "${workspaceFolder}/node_modules/.bin/next", 19 | "console": "integratedTerminal" 20 | }, 21 | { 22 | "name": "Unit Tests (Local)", 23 | "type": "node", 24 | "request": "launch", 25 | "program": "${workspaceFolder}/node_modules/.bin/jest", 26 | "console": "integratedTerminal" 27 | }, 28 | { 29 | "name": "Current File as Unit Test (Local)", 30 | "type": "node", 31 | "request": "launch", 32 | "program": "${workspaceFolder}/node_modules/.bin/jest", 33 | "args": [ 34 | "${file}" 35 | ], 36 | "console": "integratedTerminal" 37 | }, 38 | ], 39 | "compounds": [] 40 | }, 41 | "folders": [ 42 | { 43 | "path": "." 44 | } 45 | ], 46 | "settings": {}, 47 | "extensions": { 48 | "recommendations": [ 49 | "ms-azuretools.vscode-docker" 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /components/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import Button from './Button' 4 | 5 | storiesOf('Button', module).add('with text', () => { 6 | return 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | site: 5 | build: 6 | context: . 7 | target: test-target 8 | args: 9 | npm_install_command: install 10 | volumes: 11 | # This bind mount allows changes on the host file system to affect the container. 12 | # Saving a code file can cause an incremental build, a hot reload in the browser, 13 | # and a reconnection of the debugger. 14 | # The mount is in delegated mode: 15 | # Changes on the host, like saving a code file, immediately affect the container. 16 | # Changes within the container, like build output, make their way back to the host, 17 | # but are not strictly synchronized. 18 | - .:/usr/src/app:delegated 19 | ports: 20 | - 3000:3000 21 | - 9229:9229 22 | command: npm run dev 23 | storybook: 24 | build: 25 | context: . 26 | target: test-target 27 | args: 28 | npm_install_command: install 29 | volumes: 30 | - .:/usr/src/app:delegated 31 | ports: 32 | - 6006:6006 33 | command: npm run storybook 34 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.tsx?$': 'ts-jest', 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /manifests/deployment-template.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1beta1 2 | kind: Deployment 3 | metadata: 4 | name: example-deployment 5 | spec: 6 | replicas: 1 7 | template: 8 | metadata: 9 | labels: 10 | app: example-app 11 | spec: 12 | containers: 13 | - name: example-container 14 | image: ${IMAGE}:${VERSION} 15 | ports: 16 | - containerPort: 3000 17 | -------------------------------------------------------------------------------- /manifests/service-template.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: example-service 5 | spec: 6 | type: LoadBalancer 7 | ports: 8 | - port: 3000 9 | selector: 10 | app: example-app -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mess-with-node-in-docker", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "NODE_OPTIONS='--inspect=0.0.0.0' next", 6 | "build": "next build", 7 | "start": "next start", 8 | "storybook": "start-storybook --port 6006 --config-dir .storybook --quiet", 9 | "test:unit": "jest" 10 | }, 11 | "dependencies": { 12 | "next": "^9.0.5", 13 | "react": "^16.9.0", 14 | "react-dom": "^16.9.0" 15 | }, 16 | "devDependencies": { 17 | "@storybook/react": "^5.1.11", 18 | "@types/jest": "^24.0.18", 19 | "@types/node": "^12.7.4", 20 | "@types/react": "^16.9.2", 21 | "@types/react-dom": "^16.9.0", 22 | "@types/storybook__react": "^4.0.2", 23 | "fork-ts-checker-webpack-plugin": "^1.5.0", 24 | "jest": "^24.9.0", 25 | "ts-jest": "^24.0.2", 26 | "typescript": "^3.6.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | 3 | const Home: NextPage<{ userAgent: string }> = ({ userAgent }) => ( 4 |

Hello world from {process.env.NODE_ENV} - user agent: {userAgent}

5 | ); 6 | 7 | Home.getInitialProps = async ({ req }) => { 8 | const userAgent = req ? req.headers['user-agent'] || '' : navigator.userAgent; 9 | return { userAgent }; 10 | }; 11 | 12 | export default Home; 13 | -------------------------------------------------------------------------------- /scripts/build-image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | function exit_with_usage { 4 | cat >&2 < -t [-v ] 8 | ./build-image.sh --image --target [--version ] 9 | EOF 10 | 11 | exit 1 12 | } 13 | 14 | while [ "$#" -gt 0 ] ; do 15 | case "$1" in 16 | --image|-i) 17 | shift 18 | image="$1" 19 | ;; 20 | --target|-t) 21 | shift 22 | target="$1" 23 | ;; 24 | --version|-v) 25 | shift 26 | version="$1" 27 | ;; 28 | *) 29 | echo "Unexpected: $1" >&2 30 | exit_with_usage 31 | ;; 32 | esac 33 | shift || true 34 | done 35 | 36 | if [ ! "$image" ] ; then 37 | echo "Required: --image" >&2 38 | exit_with_usage 39 | fi 40 | 41 | if [ "$target" != "test-target" -a "$target" != "build-target" -a "$target" != "archive-target" ] ; then 42 | echo "Invalid: --target" >&2 43 | exit_with_usage 44 | fi 45 | 46 | if [ "$target" = "archive-target" ] ; then 47 | if [ ! "$version" ] ; then 48 | echo "Required: --version" >&2 49 | exit_with_usage 50 | fi 51 | fi 52 | 53 | if [ "$target" = "test-target" ] ; then 54 | # Download the last build stage image, which may contain reusable layers 55 | docker pull "$image:build" || true 56 | 57 | # Build and tag the test stage image 58 | docker build \ 59 | --target test-target \ 60 | --cache-from "$image:build" \ 61 | --tag "$image:test" \ 62 | . 63 | fi 64 | 65 | if [ "$target" = "build-target" ] ; then 66 | # Build and tag the build stage image 67 | docker build \ 68 | --target build-target \ 69 | --cache-from "$image:test" \ 70 | --cache-from "$image:build" \ 71 | --tag "$image:build" \ 72 | . 73 | fi 74 | 75 | if [ "$target" = "archive-target" ] ; then 76 | # Download the last archive stage image, which may contain reusable layers 77 | docker pull "$image:latest" || true 78 | 79 | # Build and tag the archive stage image 80 | docker build \ 81 | --target archive-target \ 82 | --cache-from "$image:build" \ 83 | --cache-from "$image:latest" \ 84 | --tag "$image:$version" \ 85 | --tag "$image:latest" \ 86 | . 87 | fi 88 | -------------------------------------------------------------------------------- /scripts/deploy-image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | function exit_with_usage { 4 | cat >&2 < -v [-d] 8 | ./deploy-image.sh --image --version [--delete] 9 | EOF 10 | 11 | exit 1 12 | } 13 | 14 | while [ "$#" -gt 0 ] ; do 15 | case "$1" in 16 | --image|-i) 17 | shift 18 | export IMAGE="$1" 19 | ;; 20 | --version|-v) 21 | shift 22 | export VERSION="$1" 23 | ;; 24 | --delete|-d) 25 | delete=true 26 | ;; 27 | *) 28 | echo "Unexpected: $1" >&2 29 | exit_with_usage 30 | ;; 31 | esac 32 | shift || true 33 | done 34 | 35 | if [ ! "$IMAGE" ] ; then 36 | echo "Required: --image" >&2 37 | exit_with_usage 38 | fi 39 | 40 | if [ ! "$VERSION" ] ; then 41 | echo "Required: --version" >&2 42 | exit_with_usage 43 | fi 44 | 45 | mkdir -p /tmp/manifests 46 | deployment_path=/tmp/manifests/deployment.yml 47 | service_path=/tmp/manifests/service.yml 48 | 49 | # Fill in values of IMAGE and VERSION using templates. 50 | eval echo "\"$(< manifests/deployment-template.yml)\"" > $deployment_path 51 | eval echo "\"$(< manifests/service-template.yml)\"" > $service_path 52 | 53 | if [ "$delete" ] ; then 54 | command="delete" 55 | else 56 | command="apply" 57 | fi 58 | 59 | kubectl "$command" -f "$deployment_path" -f "$service_path" 60 | -------------------------------------------------------------------------------- /scripts/push-image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | function exit_with_usage { 4 | cat >&2 < -t [-v ] 8 | ./push-image.sh --image --target [--version ] 9 | EOF 10 | 11 | exit 1 12 | } 13 | 14 | while [ "$#" -gt 0 ] ; do 15 | case "$1" in 16 | --image|-i) 17 | shift 18 | image="$1" 19 | ;; 20 | --target|-t) 21 | shift 22 | target="$1" 23 | ;; 24 | --version|-v) 25 | shift 26 | version="$1" 27 | ;; 28 | *) 29 | echo "Unexpected: $1" >&2 30 | exit_with_usage 31 | ;; 32 | esac 33 | shift || true 34 | done 35 | 36 | if [ ! "$image" ] ; then 37 | echo "Required: --image" >&2 38 | exit_with_usage 39 | fi 40 | 41 | if [ "$target" != "build-target" -a "$target" != "archive-target" ] ; then 42 | echo "Invalid: --target" >&2 43 | exit_with_usage 44 | fi 45 | 46 | if [ "$target" = "build-target" ] ; then 47 | docker push "$image:build" 48 | fi 49 | 50 | if [ "$target" = "archive-target" ] ; then 51 | if [ ! "$version" ] ; then 52 | echo "Required: --version" >&2 53 | exit_with_usage 54 | fi 55 | 56 | docker push "$image:$version" 57 | docker push "$image:latest" 58 | fi 59 | -------------------------------------------------------------------------------- /scripts/run-continuous-integration.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | function exit_with_usage { 4 | cat >&2 < -v 8 | ./run-continuous-integration.sh --image --version 9 | EOF 10 | 11 | exit 1 12 | } 13 | 14 | while [ "$#" -gt 0 ] ; do 15 | case "$1" in 16 | --image|-i) 17 | shift 18 | image="$1" 19 | ;; 20 | --version|-v) 21 | shift 22 | version="$1" 23 | ;; 24 | *) 25 | echo "Unexpected: $1" >&2 26 | exit_with_usage 27 | ;; 28 | esac 29 | shift || true 30 | done 31 | 32 | if [ ! "$image" ] ; then 33 | echo "Required: --image" >&2 34 | exit_with_usage 35 | fi 36 | 37 | if [ ! "$version" ] ; then 38 | echo "Required: --version" >&2 39 | exit_with_usage 40 | fi 41 | 42 | echo '***' Build an image through the test stage 43 | ./scripts/build-image.sh --image "$image" --target test-target 44 | 45 | echo '***' Run internal tests in a test stage container 46 | ./scripts/run-internal-tests.sh --image "$image" 47 | 48 | echo '***' Build an image through the build stage 49 | ./scripts/build-image.sh --image "$image" --target build-target 50 | 51 | echo '***' Build an image through the archive stage 52 | ./scripts/build-image.sh --image "$image" --target archive-target --version "$version" 53 | 54 | echo '***' Run external tests against the release web server on an archive stage container 55 | ./scripts/run-external-tests.sh --image "$image" 56 | 57 | echo '***' Push the build stage image 58 | ./scripts/push-image.sh --image "$image" --target build-target 59 | 60 | # (Here you would quit early if running the workflow in an unmerged feature branch) 61 | 62 | echo '***' Push the archive stage image 63 | ./scripts/push-image.sh --image "$image" --target archive-target --version "$version" 64 | 65 | echo '***' Deploy the archive stage to Kubernetes 66 | ./scripts/deploy-image.sh --image "$image" --version "$version" 67 | -------------------------------------------------------------------------------- /scripts/run-external-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | function exit_with_usage { 4 | cat >&2 < 8 | ./run-external-tests.sh --image 9 | EOF 10 | 11 | exit 1 12 | } 13 | 14 | while [ "$#" -gt 0 ] ; do 15 | case "$1" in 16 | --image|-i) 17 | shift 18 | image="$1" 19 | ;; 20 | *) 21 | echo "Unexpected: $1" >&2 22 | exit_with_usage 23 | ;; 24 | esac 25 | shift || true 26 | done 27 | 28 | if [ ! "$image" ] ; then 29 | echo "Required: --image" >&2 30 | exit_with_usage 31 | fi 32 | 33 | server_name="server" 34 | 35 | # Run the release web server on an archive image container 36 | docker run \ 37 | --name "$server_name" \ 38 | --publish 3000:3000 \ 39 | --detach \ 40 | "$image:latest" 41 | 42 | # Run external tests against the release web server, 43 | # preserving the exit code 44 | true # (Here you would run end-to-end tests against http://localhost:3000.) 45 | result=$? 46 | 47 | # Stop and discard the container 48 | docker stop "$server_name" 49 | docker rm "$server_name" 50 | 51 | exit $result 52 | -------------------------------------------------------------------------------- /scripts/run-internal-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | function exit_with_usage { 4 | cat >&2 < 8 | ./run-internal-tests.sh --image 9 | EOF 10 | 11 | exit 1 12 | } 13 | 14 | while [ "$#" -gt 0 ] ; do 15 | case "$1" in 16 | --image|-i) 17 | shift 18 | image="$1" 19 | ;; 20 | *) 21 | echo "Unexpected: $1" >&2 22 | exit_with_usage 23 | ;; 24 | esac 25 | shift || true 26 | done 27 | 28 | if [ ! "$image" ] ; then 29 | echo "Required: --image" >&2 30 | exit_with_usage 31 | fi 32 | 33 | # Run internal tests on a test image container 34 | docker run \ 35 | --rm \ 36 | "$image:test" \ 37 | npm run test:unit 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve" 20 | }, 21 | "exclude": [ 22 | "node_modules" 23 | ], 24 | "include": [ 25 | "next-env.d.ts", 26 | "**/*.ts", 27 | "**/*.tsx" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /utility/fizzBuzz.test.ts: -------------------------------------------------------------------------------- 1 | 2 | import {fizzBuzz} from "./fizzBuzz"; 3 | test("FizzBuzz test", () =>{ 4 | expect(fizzBuzz(2)).toBe("1 2 "); 5 | 6 | expect(fizzBuzz(3)).toBe("1 2 Fizz "); 7 | }); 8 | -------------------------------------------------------------------------------- /utility/fizzBuzz.ts: -------------------------------------------------------------------------------- 1 | export function fizzBuzz(n: number): string { 2 | let output = ""; 3 | for (let i = 1; i <= n; i++) { 4 | if (i % 5 && i % 3) { 5 | output += i + ' '; 6 | } 7 | if (i % 3 === 0) { 8 | output += 'Fizz '; 9 | } 10 | if (i % 5 === 0) { 11 | output += 'Buzz '; 12 | } 13 | } 14 | return output; 15 | } 16 | --------------------------------------------------------------------------------