├── .gitignore ├── .dockerignore ├── src ├── db │ ├── knex.js │ ├── seeds │ │ └── todos.js │ └── migrations │ │ └── 20181009160908_todos.js └── server.js ├── kubernetes ├── secret.yaml ├── node-service.yaml ├── postgres-service.yaml ├── volume-claim.yaml ├── volume.yaml ├── node-deployment.yaml ├── node-deployment-updated.yaml └── postgres-deployment.yaml ├── Dockerfile ├── package.json ├── docker-compose.yml ├── knexfile.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | Dockerfile 4 | docker-compose.yml 5 | .gitignore 6 | -------------------------------------------------------------------------------- /src/db/knex.js: -------------------------------------------------------------------------------- 1 | const environment = process.env.NODE_ENV || 'development'; 2 | const config = require('../../knexfile')[environment]; 3 | module.exports = require('knex')(config); 4 | -------------------------------------------------------------------------------- /kubernetes/secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: postgres-credentials 5 | type: Opaque 6 | data: 7 | user: c2FtcGxl 8 | password: cGxlYXNlY2hhbmdlbWU= 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:17-alpine 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package.json ./ 6 | 7 | RUN npm install 8 | RUN npm install -g knex 9 | 10 | COPY . . 11 | 12 | EXPOSE 3000 13 | 14 | CMD [ "npm", "start" ] 15 | -------------------------------------------------------------------------------- /kubernetes/node-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: node 5 | labels: 6 | service: node 7 | spec: 8 | selector: 9 | app: node 10 | type: LoadBalancer 11 | ports: 12 | - port: 3000 13 | -------------------------------------------------------------------------------- /kubernetes/postgres-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: postgres 5 | labels: 6 | service: postgres 7 | spec: 8 | selector: 9 | service: postgres 10 | type: ClusterIP 11 | ports: 12 | - port: 5432 13 | -------------------------------------------------------------------------------- /kubernetes/volume-claim.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | metadata: 4 | name: postgres-pvc 5 | labels: 6 | type: local 7 | spec: 8 | accessModes: 9 | - ReadWriteOnce 10 | resources: 11 | requests: 12 | storage: 50Gi 13 | volumeName: postgres-pv 14 | -------------------------------------------------------------------------------- /kubernetes/volume.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolume 3 | metadata: 4 | name: postgres-pv 5 | labels: 6 | name: postgres-pv 7 | spec: 8 | capacity: 9 | storage: 50Gi 10 | storageClassName: standard 11 | accessModes: 12 | - ReadWriteOnce 13 | gcePersistentDisk: 14 | pdName: pg-data-disk 15 | fsType: ext4 16 | -------------------------------------------------------------------------------- /src/db/seeds/todos.js: -------------------------------------------------------------------------------- 1 | 2 | exports.seed = (knex, Promise) => { 3 | // Deletes ALL existing entries 4 | return knex('todos').del() 5 | .then(() => { 6 | // Inserts seed entries 7 | return knex('todos').insert([ 8 | {title: 'Do something', completed: false}, 9 | {title: 'Do something else', completed: false} 10 | ]); 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-kubernetes", 3 | "main": "index.js", 4 | "scripts": { 5 | "start": "nodemon src/server.js" 6 | }, 7 | "license": "MIT", 8 | "dependencies": { 9 | "body-parser": "^1.19.0", 10 | "express": "^4.17.1", 11 | "knex": "^0.95.14", 12 | "pg": "^8.7.1" 13 | }, 14 | "devDependencies": { 15 | "nodemon": "^2.0.15" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/db/migrations/20181009160908_todos.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex, Promise) { 2 | return knex.schema.createTable('todos', (table) => { 3 | table.increments(); 4 | table.string('title').notNullable(); 5 | table.boolean('completed').notNullable().defaultTo(false); 6 | }); 7 | }; 8 | 9 | exports.down = function(knex, Promise) { 10 | return knex.schema.dropTable('todos'); 11 | }; 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | 5 | web: 6 | build: . 7 | environment: 8 | - NODE_ENV=development 9 | - PORT=3000 10 | - POSTGRES_USER=postgres 11 | - POSTGRES_PASSWORD=postgres 12 | ports: 13 | - 3000:3000 14 | depends_on: 15 | - postgres 16 | 17 | postgres: 18 | image: postgres:14-alpine 19 | environment: 20 | - POSTGRES_USER=postgres 21 | - POSTGRES_PASSWORD=postgres 22 | - POSTGRES_DB=todos 23 | expose: 24 | - 5432 25 | -------------------------------------------------------------------------------- /kubernetes/node-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: node 5 | labels: 6 | name: node 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: node 12 | template: 13 | metadata: 14 | labels: 15 | app: node 16 | spec: 17 | containers: 18 | - name: node 19 | image: gcr.io//node-kubernetes:v0.0.1 # update 20 | env: 21 | - name: NODE_ENV 22 | value: "development" 23 | - name: PORT 24 | value: "3000" 25 | restartPolicy: Always 26 | -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | const POSTGRES_USER = process.env.POSTGRES_USER; 2 | const POSTGRES_PASSWORD = process.env.POSTGRES_PASSWORD; 3 | const DATABASE_URL = `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/todos` 4 | 5 | 6 | module.exports = { 7 | development: { 8 | client: 'pg', 9 | connection: DATABASE_URL, 10 | migrations: { 11 | directory: `${__dirname}/src/db/migrations`, 12 | }, 13 | seeds: { 14 | directory: `${__dirname}/src/db/seeds`, 15 | }, 16 | }, 17 | production: { 18 | client: 'DATABASE_URL', 19 | connection: DATABASE_URL, 20 | migrations: { 21 | directory: `${__dirname}/src/db/migrations`, 22 | }, 23 | seeds: { 24 | directory: `${__dirname}/src/db/seeds`, 25 | }, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /kubernetes/node-deployment-updated.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: node 5 | labels: 6 | name: node 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: node 12 | template: 13 | metadata: 14 | labels: 15 | app: node 16 | spec: 17 | containers: 18 | - name: node 19 | image: gcr.io//node-kubernetes:v0.0.1 # update 20 | env: 21 | - name: NODE_ENV 22 | value: "development" 23 | - name: PORT 24 | value: "3000" 25 | - name: POSTGRES_USER 26 | valueFrom: 27 | secretKeyRef: 28 | name: postgres-credentials 29 | key: user 30 | - name: POSTGRES_PASSWORD 31 | valueFrom: 32 | secretKeyRef: 33 | name: postgres-credentials 34 | key: password 35 | restartPolicy: Always 36 | -------------------------------------------------------------------------------- /kubernetes/postgres-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: postgres 5 | labels: 6 | name: database 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | service: postgres 12 | template: 13 | metadata: 14 | labels: 15 | service: postgres 16 | spec: 17 | containers: 18 | - name: postgres 19 | image: postgres:14-alpine 20 | volumeMounts: 21 | - name: postgres-volume-mount 22 | mountPath: /var/lib/postgresql/data 23 | subPath: postgres 24 | env: 25 | - name: POSTGRES_USER 26 | valueFrom: 27 | secretKeyRef: 28 | name: postgres-credentials 29 | key: user 30 | - name: POSTGRES_PASSWORD 31 | valueFrom: 32 | secretKeyRef: 33 | name: postgres-credentials 34 | key: password 35 | restartPolicy: Always 36 | volumes: 37 | - name: postgres-volume-mount 38 | persistentVolumeClaim: 39 | claimName: postgres-pvc 40 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | /* 2 | imports 3 | */ 4 | 5 | const express = require('express'); 6 | const bodyParser = require('body-parser'); 7 | 8 | const app = express(); 9 | const PORT = process.env.PORT || 3000; 10 | const knex = require('./db/knex'); 11 | 12 | /* 13 | middleware 14 | */ 15 | 16 | app.use(bodyParser.json()); 17 | app.use(bodyParser.urlencoded({ extended: false })); 18 | 19 | /* 20 | routes 21 | */ 22 | 23 | app.get('/', (req, res) => { 24 | res.json('pong!'); 25 | }); 26 | 27 | app.get('/todos', (req, res) => { 28 | knex('todos') 29 | .then((data) => { res.json(data); }) 30 | .catch(() => { res.json('Something went wrong.') }); 31 | }); 32 | 33 | app.get('/todos/:id', (req, res) => { 34 | knex('todos') 35 | .where({ id: parseInt(req.params.id) }) 36 | .then((data) => { res.json(data); }) 37 | .catch(() => { res.json('Something went wrong.') }); 38 | }); 39 | 40 | app.post('/todos', (req, res) => { 41 | knex('todos') 42 | .insert({ 43 | title: req.body.title, 44 | completed: false 45 | }) 46 | .then(() => { res.json('Todo added!'); }) 47 | .catch(() => { res.json('Something went wrong.') }); 48 | }); 49 | 50 | app.put('/todos/:id', (req, res) => { 51 | knex('todos') 52 | .where({ id: parseInt(req.params.id) }) 53 | .update({ 54 | title: req.body.title, 55 | completed: req.body.completed 56 | }) 57 | .then(() => { res.json('Todo updated!'); }) 58 | .catch(() => { res.json('Something went wrong.') }); 59 | }); 60 | 61 | app.delete('/todos/:id', (req, res) => { 62 | knex('todos') 63 | .where({ id: parseInt(req.params.id) }) 64 | .del() 65 | .then(() => { res.json('Todo deleted!'); }) 66 | .catch(() => { res.json('Something went wrong.') }); 67 | }); 68 | 69 | 70 | /* 71 | run server 72 | */ 73 | 74 | app.listen(PORT, () => { 75 | console.log(`Listening on port: ${PORT}`); 76 | }); 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deploying a Node App to Google Cloud with Kubernetes 2 | 3 | ## Want to learn how to build this? 4 | 5 | Check out the [post](https://testdriven.io/deploying-a-node-app-to-google-cloud-with-kubernetes). 6 | 7 | ## Want to use this project? 8 | 9 | ### Docker 10 | 11 | Build the images and spin up the containers: 12 | 13 | ```sh 14 | $ docker-compose up -d --build 15 | ``` 16 | 17 | Run the migrations and seed the database: 18 | 19 | ```sh 20 | $ docker-compose exec web knex migrate:latest 21 | $ docker-compose exec web knex seed:run 22 | ``` 23 | 24 | Test it out at: 25 | 26 | 1. [http://localhost:3000](http://localhost:3000) 27 | 1. [http://localhost:3000/todos](http://localhost:3000/todos) 28 | 29 | ### Kubernetes 30 | 31 | #### Google Cloud Platform (GCP) 32 | 33 | Install the [Google Cloud SDK](https://cloud.google.com/sdk), run `gcloud init` to configure it, and then either pick an existing GCP project or create a new project to work with. 34 | 35 | Set the project: 36 | 37 | ```sh 38 | $ gcloud config set project 39 | ``` 40 | 41 | Install `kubectl`: 42 | 43 | ```sh 44 | $ gcloud components install kubectl 45 | ``` 46 | 47 | #### Kubernetes Cluster 48 | 49 | Create a cluster on [Kubernetes Engine](https://console.cloud.google.com/kubernetes): 50 | 51 | ```sh 52 | $ gcloud container clusters create node-kubernetes \ 53 | --num-nodes=3 --zone us-central1-a --machine-type g1-small 54 | ``` 55 | 56 | Connect the `kubectl` client to the cluster: 57 | 58 | ```sh 59 | $ gcloud container clusters get-credentials node-kubernetes --zone us-central1-a 60 | ``` 61 | 62 | #### Docker 63 | 64 | Build and push the image to the [Container Registry](https://cloud.google.com/container-registry/): 65 | 66 | ```sh 67 | $ gcloud auth configure-docker 68 | $ docker build -t gcr.io//node-kubernetes:v0.0.1 . 69 | $ docker push gcr.io//node-kubernetes:v0.0.1 70 | ``` 71 | 72 | #### Secrets 73 | 74 | Create the secret object: 75 | 76 | ```sh 77 | $ kubectl apply -f ./kubernetes/secret.yaml 78 | ``` 79 | 80 | #### Volume 81 | 82 | Create a [Persistent Disk](https://cloud.google.com/persistent-disk/): 83 | 84 | ```sh 85 | $ gcloud compute disks create pg-data-disk --size 50GB --zone us-central1-a 86 | ``` 87 | 88 | Create the volume: 89 | 90 | ```sh 91 | $ kubectl apply -f ./kubernetes/volume.yaml 92 | ``` 93 | 94 | Create the volume claim: 95 | 96 | ```sh 97 | $ kubectl apply -f ./kubernetes/volume-claim.yaml 98 | ``` 99 | 100 | #### Postgres 101 | 102 | Create deployment: 103 | 104 | ```sh 105 | $ kubectl create -f ./kubernetes/postgres-deployment.yaml 106 | ``` 107 | 108 | Create the service: 109 | 110 | ```sh 111 | $ kubectl create -f ./kubernetes/postgres-service.yaml 112 | ``` 113 | 114 | Create the database: 115 | 116 | ```sh 117 | $ kubectl get pods 118 | $ kubectl exec --stdin --tty -- createdb -U sample todos 119 | ``` 120 | 121 | #### Node 122 | 123 | Update the image name *kubernetes/node-deployment-updated.yaml* and then create the deployment: 124 | 125 | ```sh 126 | $ kubectl create -f ./kubernetes/node-deployment-updated.yaml 127 | ``` 128 | 129 | Create the service: 130 | 131 | ```sh 132 | $ kubectl create -f ./kubernetes/node-service.yaml 133 | ``` 134 | 135 | Apply the migration and seed the database: 136 | 137 | ```sh 138 | $ kubectl get pods 139 | $ kubectl exec knex migrate:latest 140 | $ kubectl exec knex seed:run 141 | ``` 142 | 143 | Grab the external IP: 144 | 145 | ```sh 146 | $ kubectl get service node 147 | 148 | NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE 149 | node LoadBalancer 10.39.244.136 35.232.249.48 3000:30743/TCP 2m 150 | ``` 151 | 152 | Test it out: 153 | 154 | 1. [http://EXTERNAL_IP:3000](http://EXTERNAL_IP:3000) 155 | 1. [http://EXTERNAL_IP:3000/todos](http://EXTERNAL_IP:3000/todos) 156 | 157 | #### Remove 158 | 159 | Remove the resources once done: 160 | 161 | ```sh 162 | $ kubectl delete -f ./kubernetes/node-service.yaml 163 | $ kubectl delete -f ./kubernetes/node-deployment-updated.yaml 164 | 165 | $ kubectl delete -f ./kubernetes/secret.yaml 166 | 167 | $ kubectl delete -f ./kubernetes/volume.yaml 168 | $ kubectl delete -f ./kubernetes/volume-claim.yaml 169 | 170 | $ kubectl delete -f ./kubernetes/postgres-deployment.yaml 171 | $ kubectl delete -f ./kubernetes/postgres-service.yaml 172 | 173 | $ gcloud container clusters delete node-kubernetes --zone us-central1-a 174 | $ gcloud compute disks delete pg-data-disk --zone us-central1-a 175 | $ gcloud container images delete gcr.io//node-kubernetes:v0.0.1 176 | ``` 177 | --------------------------------------------------------------------------------