Author: Quan Hua
Last Updated: Fri, Sep 23, 2022Next.js is a popular React framework for developing static websites and modern web applications. Next.js application usually deploys to a serverless platform. However, there are some scenarios that you want to deploy to your controlled server and scale it in your infrastructure. This tutorial explains how to deploy a full-stack Next.js application to Vultr Kubernetes Engine. The example application is built with Next.js and uses Prisma to connect with a MySQL database.
Here are a few advantages of this approach:
Deploy the Next.js application as close as possible to the database.
Avoid the cold-starts problem in the serverless approach.
Predictable pricing of cloud servers and bandwidth compared to serverless platforms.
There are three separated parts in this tutorial:
Part 1: Build and push the Docker image to Docker Hub Image Registry
Part 2: Deploy and scale the Next.js application with Vultr Kubernetes Engine
Part 3: Expose the Next.js application with secure SSL certificates
Before you begin, you should:
Have an external MySQL Database
Deploy a Vultr Kubernetes Cluster with at least 3 nodes.
Configure kubectl
and git
in your machine.
Install Docker on your machine
Register a Docker Hub account to store the Docker Image
The Next.js application used in this tutorial is a full-stack application called next-short-urls
. The source code for this application can be found at this repository. Vultr has also archived the repo here.
This is a simple URL Shortener built with Next.js, TypeScript, and Prisma. Prisma is an open-source ORM that helps you build and manage the MySQL database.
Clone the source code of the example application:
$ git clone -b v1.10.0 https://github.com/quanhua92/next-short-urls
Prepare a .env
file environment with the below content. This .env
file is used in the building stage of the Docker image to generate static pages. The final production Docker image does not contain in this file.
DATABASE_URL="DATABASE_URL_HERE"
SECRET_COOKIE_PASSWORD="SOME_SECRET_PASSWORD_WITH_MIN_LENGTH_32"
AWAIT_HISTORY_UPDATE="0"
Here is a brief explanation of the content of .env
file:
DATABASE_URL
is a database connection string started with mysql://
SECRET_COOKIE_PASSWORD
is an application specified password with a minimum length of 32
AWAIT_HISTORY_UPDATE
is an environment variable that the application needs.
Make sure that the DockerFile
file contains the below content. This is a multi-stage DockerFile with deps
, builder
and runner
stages.
# Install dependencies only when needed
FROM node:16-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# If using npm with a `package-lock.json` comment out above and use below instead
# COPY package.json package-lock.json /
# RUN npm install
# Rebuild the source code only when needed
FROM node:16-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# generate the prisma type
RUN npx prisma generate
RUN yarn build && yarn install --production --ignore-scripts --prefer-offline
# Production image, copy all the files and run next
FROM node:16-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
# Prepare the cache folder for next/image
RUN mkdir -p /app/.next/cache/images && chown nextjs:nodejs /app/.next/cache/images
VOLUME /app/.next/cache/images
# You only need to copy next.config.js if you are NOT using the default configuration
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
USER nextjs
EXPOSE 3000
ENV PORT 3000
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry.
# ENV NEXT_TELEMETRY_DISABLED 1
CMD ["node_modules/.bin/next", "start"]
Here are some configurations that are specified to the next-short-urls
application. In the builder stage, RUN npx prisma generate
to generate the types from the Prisma schema. In the runner stage, make a folder /app/.next/cache/images
and prepare the folder permission. This is used for the Automatic Image Optimization feature in Next.js.
Build the Docker Image
$ docker build . -t next-short-urls
Login to Docker Hub
$ docker login
Tag the Docker Image with your Docker Hub ID and Push to Docker Hub. Replace <YOUR_DOCKER_HUB_ID>
with your Docker Hub account id.
$ docker tag next-short-urls <YOUR_DOCKER_HUB_ID>/next-short-urls:latest
$ docker push <YOUR_DOCKER_HUB_ID>/next-short-urls:latest
Run Docker local to verify the docker image. Navigate to http://localhost:3000 to access the application
$ docker run --rm -p 3000:3000 <YOUR_DOCKER_HUB_ID>/next-short-urls
(Optional) Go to your Docker Hub dashboard and make sure that your image is private.
In this part, you will create a secret that contains your Docker Hub credentials and create a Deployment to deploy some pods that run your Next.js application.
Create Docker Hub secret named regcred
. Replace parameters in the command with your Docker Hub credentials
$ kubectl create secret docker-registry regcred \
--docker-username=YOUR_DOCKER_HUB_USERNAME \
--docker-password=YOUR_DOCKER_HUB_PASSWORD \
--docker-email=YOUR_DOCKER_HUB_EMAIL
Create a Secret manifest file secrets.yaml
with the data as the previously created .env
.
apiVersion: v1
kind: Secret
metadata:
name: next-short-urls-secrets
namespace: default
type: Opaque
stringData:
DATABASE_URL: "DATABASE_URL_HERE"
SECRET_COOKIE_PASSWORD: "SOME_SECRET_PASSWORD_WITH_MIN_LENGTH_32"
AWAIT_HISTORY_UPDATE: "0"
Run the command to create the secret.
$ kubectl apply -f secrets.yaml
Create a Deployment file deployment.yaml
with the following content. Replace <YOUR_DOCKER_HUB_ID>
with your Docker Hub account ID.
apiVersion: apps/v1
kind: Deployment
metadata:
name: next-short-urls-deploy
spec:
replicas: 3
selector:
matchLabels:
name: next-short-urls-app
template:
metadata:
labels:
name: next-short-urls-app
spec:
imagePullSecrets:
- name: regcred
containers:
- name: next-short-urls
image: <YOUR_DOCKER_HUB_ID>/next-short-urls:latest
imagePullPolicy: Always
ports:
- containerPort: 3000
envFrom:
- secretRef:
name: next-short-urls-secrets
---
apiVersion: v1
kind: Service
metadata:
name: next-short-urls-service
spec:
ports:
- name: http
port: 80
protocol: TCP
targetPort: 3000
selector:
name: next-short-urls-app
Run the command to create the Deployment and Service.
$ kubectl apply -f deployment.yaml
Run the command kubectl get pods
to see the newly created pods. The result should look similar to:
NAME READY STATUS RESTARTS AGE
next-short-urls-deploy-764d658d49-26b5w 1/1 Running 0 62s
next-short-urls-deploy-764d658d49-hql7q 1/1 Running 0 62s
next-short-urls-deploy-764d658d49-nv6tr 1/1 Running 0 62s
Run the command kubectl get services
to see the newly created service. The result should look similar to:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 94m
next-short-urls-service ClusterIP 10.99.247.34 <none> 80/TCP 39s
Scale your application to 10 replicas using the following command.
$ kubectl scale --replicas=10 deployment/next-short-urls-deploy
Run the command kubectl get pods
to see the newly created pods. The result should look similar to:
NAME READY STATUS RESTARTS AGE
next-short-urls-deploy-764d658d49-26b5w 1/1 Running 0 3m
next-short-urls-deploy-764d658d49-5r25t 1/1 Running 0 25s
next-short-urls-deploy-764d658d49-hql7q 1/1 Running 0 3m
next-short-urls-deploy-764d658d49-j6qlf 1/1 Running 0 25s
next-short-urls-deploy-764d658d49-lvrgp 1/1 Running 0 25s
next-short-urls-deploy-764d658d49-nv6tr 1/1 Running 0 3m
next-short-urls-deploy-764d658d49-vkl98 1/1 Running 0 25s
next-short-urls-deploy-764d658d49-wkclv 1/1 Running 0 25s
next-short-urls-deploy-764d658d49-xgzs6 1/1 Running 0 25s
next-short-urls-deploy-764d658d49-zhcdj 1/1 Running 0 25s
Perform port-forwarding to access your application through the service. Navigate to http://localhost:8080 to access your application.
$ kubectl port-forward services/next-short-urls-service 8080:80
You have successfully deployed the Next.js application with MySQL Database to Vultr Kubernetes Engine with 10 replicas in your cluster.
In this part, you will install NGINX Ingress Controller and expose your application through a domain name with secure SSL certificates.
Install ingress-nginx
.
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.1.1/deploy/static/provider/cloud/deploy.yaml
Go to your Load Balancers dashboard at https://my.vultr.com/loadbalancers/ and get the IP Address of the newly created Load Balancer. This is the Load Balancer created for the NGINX ingress.
Create an A record in your domain DNS that points to the above IP address.
Install cert-manager
to manage SSL certificates
$ kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.7.0/cert-manager.yaml
Create a manifest file letsencrypt.yaml
to handle Let's Encrypt certificates. Replace <YOUR_EMAIL> with your actual email.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
# The ACME server URL
server: https://acme-staging-v02.api.letsencrypt.org/directory
preferredChain: "ISRG Root X1"
# Email address used for ACME registration
email: <YOUR_EMAIL>
# Name of a secret used to store the ACME account private key
privateKeySecretRef:
name: letsencrypt-staging
solvers:
- http01:
ingress:
class: nginx
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
# The ACME server URL
server: https://acme-v02.api.letsencrypt.org/directory
# Email address used for ACME registration
email: <YOUR_EMAIL>
# Name of a secret used to store the ACME account private key
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: nginx
Run the command to install the above Letâs Encrypt issuers.
$ kubectl apply -f letsencrypt.yaml
Create an Ingress manifest file ingress.yaml
with the following content. Replace <YOUR_DOMAIN> with the domain that you have created A record in the above step.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: next-short-urls-ingress
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- secretName: next-short-urls-tls
hosts:
- <YOUR_DOMAIN>
rules:
- host: <YOUR_DOMAIN>
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: next-short-urls-service
port:
number: 80
Run the command to create the ingress.
$ kubectl apply -f ingress.yaml
Run the command kubectl get ingress
to see the newly created ingress. The result should look similar to:
NAME CLASS HOSTS ADDRESS PORTS AGE
next-short-urls-ingress <none> <YOUR_DOMAIN> 140.82.41.69 80, 443 37s
Navigate to https://<YOUR_DOMAIN
to access your application.