Deployment Example
A deployable web application example.
A deployable web application example.
Follow the steps on this page to create a new devcontainer and GitHub Codespace: https://textbooks.cs.ksu.edu/cis526/x-examples/01-express-starter/01-codespace/index.html.
Choose the Node.js & PostgreSQL container type with Node version 22-bookworm.
Also consider adding the docker-outside-of-docker feature by selecting it from the list or adding it directly to your devcontainer.json file. This allows you to see and control the PosgreSQL container. See [this link] for details.
All commands and files in this section should be performed in the server directory that is created as part of the first step.
Follow the steps on this page to create a new Express server: https://textbooks.cs.ksu.edu/cis526/x-examples/01-express-starter/02-express-starter/index.html
Use the npm-check-updates comamnd to update all installed packages.
Install dotenvx as shown on this page: https://textbooks.cs.ksu.edu/cis526/x-examples/01-express-starter/07-environment/index.html
Add to top of app.js
require('@dotenvx/dotenvx').config()Create .env file with these contents:
port=3000Install libraries:
$ npm install sequelize umzug pg pg-hstoreDatabase configuration file config/database.js:
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize({
// Supports "sqlite" or "postgres"
dialect: "postgres",
host: process.env.DATABASE_HOST || "db",
port: process.env.DATABASE_PORT || 5432,
username: process.env.DATABASE_USERNAME || "postgres",
password: process.env.DATABASE_PASSWORD || "postgres",
database: process.env.DATABASE_NAME || "postgres",
})
module.exports = sequelize;Update .env file with environment:
PORT=3000
DATABASE_HOST=db
DATABASE_PORT=5432
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres
DATABASE_NAME=postgresCreate migration configuration config/migrations.js
const { Umzug, SequelizeStorage } = require('umzug');
const database = require('./database')
const umzug = new Umzug({
migrations: {glob: 'migrations/*.js'},
context: database.getQueryInterface(),
storage: new SequelizeStorage({
sequelize: database,
modelName: 'migrations'
}),
})
module.exports = umzug;Create script migrate.js
require('@dotenvx/dotenvx').config()
const migrations = require('./config/migrations')
migrations.runAsCLI()Create migration migrations/00_users.js
const { Sequelize } = require('sequelize');
async function up({context: queryInterface}) {
await queryInterface.createTable('users', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
},
username: {
type: Sequelize.STRING,
unique: true,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
})
}
async function down({context: queryInterface}) {
await queryInterface.dropTable('users');
}
module.exports = {up, down}Run migration
node migrate upCreate seed configration config/seeds.js
const { Umzug, SequelizeStorage } = require('umzug');
const database = require('./database')
const umzug = new Umzug({
migrations: {glob: 'seeds/*.js'},
context: database.getQueryInterface(),
storage: new SequelizeStorage({
sequelize: database,
modelName: 'seeds'
}),
})
module.exports = umzug;Create script seed.js
require('@dotenvx/dotenvx').config()
const seeds = require('./config/seeds')
seeds.runAsCLI()Create seed file seeds/00_users.js
const { Sequelize } = require('sequelize');
// Timestamp in the appropriate format for the database
const now = new Date().toISOString().slice(0, 23).replace("T", " ") + " +00:00";
// Array of objects to add to the database
const users = [
{
id: 1,
username: 'admin',
createdAt: now,
updatedAt: now
},
{
id: 2,
username: 'contributor',
createdAt: now,
updatedAt: now
},
{
id: 3,
username: 'manager',
createdAt: now,
updatedAt: now
},
{
id: 4,
username: 'user',
createdAt: now,
updatedAt: now
},
];
async function up({context: queryInterface}) {
await queryInterface.bulkInsert('users', users);
await queryInterface.sequelize.query("SELECT setval('users_id_seq', max(id)) FROM users;");
}
async function down({context: queryInterface}) {
await queryInterface.bulkDelete("users", {}, { truncate: true });
}
module.exports = {up, down}Update bin/www file to automate migration and seeding:
#!/usr/bin/env node
/**
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('server:server');
var http = require('http');
var database = require('../config/database')
var migrations = require('../config/migrations')
var seeds = require('../config/seeds')
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
var server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
// server.listen(port)
server.on('error', onError);
server.on('listening', onListening);
// Call startup function
startup();
function startup() {
try {
// Test database connection
database.authenticate().then(() => {
console.log("Database connection successful")
// Run migrations
migrations.up().then(() => {
console.log("Database migrations complete")
if (process.env.SEED_DATA === 'true') {
console.log("Database data seeding is enabled!")
seeds.up().then(() => {
console.log("Database seeding complete")
server.listen(port)
})
} else {
// Listen on provided port, on all network interfaces.
server.listen(port)
}
})
})
} catch (error){
logger.error(error)
}
}
// ======= other code here ========
Update .env file with environment:
PORT=3000
DATABASE_HOST=db
DATABASE_PORT=5432
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres
DATABASE_NAME=postgres
SEED_DATA=trueCreate model models/models.js
const { Sequelize } = require('sequelize');
const database = require('../config/database')
const UserSchema = {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
},
username: {
type: Sequelize.STRING,
unique: true,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
}
const User = database.define(
// Model Name
'User',
// Schema
UserSchema,
// Other options
{
tableName: 'users'
}
)
module.exports = { User }Edit routes/users.js
var express = require('express');
var router = express.Router();
const { User } = require('../models/models')
/* GET users listing. */
router.get('/', async function(req, res, next) {
const users = await User.findAll()
res.json(users)
});
module.exports = router;Start the app and navigate to the /users route:
$ npm startShould see API output. Great - server is working!
To prepare for Vue, update app.js to remove the index router.
All commands and files in this section should be performed in the client directory that is created as part of the first step.
Create a Vue starter project as described here: https://textbooks.cs.ksu.edu/cis526/x-examples/05-vue-starter/01-vue-starter/index.html
Can omit Pinia and Vitest features. Choose to skip all example code and start with a blank Vue project.
Npm install and check for updates
Update vite.config.js to add proxy:
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
proxy: {
'/users': {
target: 'http://localhost:3000',
changeOrigin: true,
secure: false
}
}
}
})Update src/App.vue to call API
<script setup>
import { ref } from 'vue'
const users = ref([])
fetch('/users/').then(response => response.json())
.then((response) => {
users.value = response
}).catch((e) => {
console.error(e)
})
</script>
<template>
<h1>List of Users</h1>
<ul>
<li v-for="user in users" :key="user.id">{{ user.username }}</li>
</ul>
</template>
<style scoped></style>In two terminals, start server and client and open browser. Should see a list of users
$ cd server
$ npm start$ cd client
$ npm run devAll commands and files in this section should be performed in the base directory for the project (outside of client and server).
Create Dockerfile
# Node Version
ARG NODE_VERSION=22
###############################
# STAGE 1 - BUILD CLIENT #
###############################
# Client Base Image
# See https://hub.docker.com/_/node/
FROM node:${NODE_VERSION}-alpine as client
# Use production node environment by default
ENV NODE_ENV production
# Store files in /usr/src/app
WORKDIR /usr/src/app
# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.npm to speed up subsequent builds.
# Leverage a bind mounts to package.json and package-lock.json to avoid having to copy them into
# into this layer.
# See https://docs.docker.com/build/cache/optimize/
RUN --mount=type=bind,source=client/package.json,target=package.json \
--mount=type=bind,source=client/package-lock.json,target=package-lock.json \
--mount=type=cache,target=/root/.npm \
npm ci --include=dev
# Copy the rest of the source files into the image.
COPY ./client .
# Build the client application
RUN npm run build
###############################
# STAGE 2 - BUILD SERVER #
###############################
# Server Base Image
# See https://hub.docker.com/_/node/
FROM node:${NODE_VERSION}-alpine as server
# Use production node environment by default
ENV NODE_ENV production
# Store files in /usr/src/app
WORKDIR /usr/src/app
# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.npm to speed up subsequent builds.
# Leverage a bind mounts to package.json and package-lock.json to avoid having to copy them into
# into this layer.
# See https://docs.docker.com/build/cache/optimize/
RUN --mount=type=bind,source=server/package.json,target=package.json \
--mount=type=bind,source=server/package-lock.json,target=package-lock.json \
--mount=type=cache,target=/root/.npm \
npm ci --omit=dev
# Copy the rest of the source files into the image
COPY ./server .
# Copy the built version of the client into the image
COPY --from=client /usr/src/app/dist ./public
# Make a directory for the database and make it writable
RUN mkdir -p ./data
RUN chown -R node:node ./data
# Make a directory for the uploads and make it writable
RUN mkdir -p ./public/uploads
RUN chown -R node:node ./public/uploads
# Run the application as a non-root user.
USER node
# Expose the port that the application listens on.
EXPOSE 3000
# Command to check for a healthy application
HEALTHCHECK CMD wget --no-verbose --tries=1 --spider http://localhost:3000/users || exit 1
# Run the application.
CMD npm run startCreate .github/workflows/build_docker.yml
# Workflow name
name: Build Docker
# Run only on new tags being pushed
# https://docs.github.com/en/actions/using-workflows/triggering-a-workflow
on:
push:
tags:
- 'v*.*.*'
# Define a single job named build
jobs:
build:
# Run job on Ubuntu runner
runs-on: ubuntu-latest
# Job Steps
steps:
# Step 1 - Checkout the Repository
# https://github.com/actions/checkout
- name: 1 - Checkout Repository
uses: actions/checkout@v4
# Step 2 - Log In to GitHub Container Registry
# https://github.com/docker/login-action
- name: 2 - Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# Step 3 - Build and Push Docker Image
# https://github.com/docker/build-push-action
- name: 3 - Build and Push Docker Image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:latest
# Step 4 - Make Release on GitHub
# https://github.com/softprops/action-gh-release
- name: 4 - Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: trueCreate compose.yml
services:
######################################
# Deployment Example
#
# Repository:
# https://github.com/cis526-codio/ex-deploy
example-deploy:
# Docker Image
# !!TODO - Update this to match your repository !!
image: ghcr.io/cis526-codio/ex-deploy:latest
# Container Name
container_name: example-deploy
# Restart Container Unless Stopped
restart: unless-stopped
# Networks
networks:
- default
- example-deploy-network
# Network Ports
ports:
- "3000:3000"
# Environment Variables
environment:
# Seed initial data on first startup
SEED_DATA: 'true'
# Database
DATABASE_HOST: example-deploy-db
DATABASE_PORT: 5432
DATABASE_USERNAME: example-deploy
DATABASE_PASSWORD: example-deploy
DATABASE_NAME: example-deploy
######################################
# Postgres Database
#
# Image Location:
# https://hub.docker.com/_/postgres
example-deploy-db:
# Docker Image
image: postgres:17-alpine
# Container Name
container_name: example-deploy-db
# Restart Container Unless Stopped
restart: unless-stopped
# Networks
networks:
- example-deploy-network
# Volumes
volumes:
- example-deploy-db-data:/var/lib/postgresql/data:rw
# Environment Variables
environment:
POSTGRES_USER: example-deploy
POSTGRES_PASSWORD: example-deploy
POSTGRES_DB: example-deploy
volumes:
example-deploy-db-data:
networks:
example-deploy-network:
internal: trueCommit a v0.0.1 tag, deploy, and enjoy!