DEV Community

Waffeu Rayn
Waffeu Rayn

Posted on

Guide to Containerizing a Modern JavaScript SPA (Vue/Vite/React) with a Multi-Stage Nginx Build 🚀

This guide outlines the professional, multi-stage Docker strategy required to package a built Single Page Application (SPA), ensuring the final image is secure, minimal, and correctly configured to serve the application and handle remote API calls. This architecture is valid for Vue, Vite, React, Angular, or Svelte applications.


1. Multi-Stage Dockerfile: Build vs. Serve

This file uses a two-stage build to drastically reduce the final image size (eliminating Node.js and build tools) and enhance security.

# Dockerfile

# Stage 1: Build the Application (AS build)
FROM docker.io/node:20-alpine AS build

# 1. Inject API URL at build time 
ARG VITE_API_URL=http://localhost:3000/api
ENV VITE_API_URL=$VITE_API_URL

WORKDIR /app
COPY package*.json ./
RUN npm ci 
COPY . .
RUN npm run build:deploy 

# ---

# Stage 2: Serve the Application (AS final)
FROM docker.io/library/nginx:stable-alpine AS final

# 1. Custom Nginx Configuration Setup
RUN rm /etc/nginx/nginx.conf
COPY nginx-main.conf /etc/nginx/nginx.conf
COPY nginx.conf /etc/nginx/conf.d/

# 2. Grant Non-Root User Permissions (CRITICAL for security & stability)
# Fixes 'mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)'
RUN mkdir -p /var/run/nginx \
    && chown -R nginx:nginx /var/cache/nginx \
    && chown -R nginx:nginx /var/run/nginx

# 3. Copy only the required static files from the build stage
COPY --from=build /app/dist /usr/share/nginx/html

EXPOSE 8080

# 4. Run as Non-Root User (Security Best Practice)
USER nginx

CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

2. Nginx Configuration Files (The Missing Pieces)

These custom files are necessary to resolve critical permission and port errors when running Nginx as a non-root user.

A. Main Configuration (nginx-main.conf)

This file replaces the default /etc/nginx/nginx.conf. It controls the Master Process directives.

# nginx-main.conf (Copied to /etc/nginx/nginx.conf)

# Fixes 'open() "/run/nginx.pid" failed (13: Permission denied)'
# This MUST be in the main context.
pid /var/run/nginx/nginx.pid; 

# Sets the user for worker processes (ignored by Dockerfile's USER, but good practice)
user  nginx; 
worker_processes  auto;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;

    # Include our custom server block
    include /etc/nginx/conf.d/*.conf; 
}
Enter fullscreen mode Exit fullscreen mode

B. Server Block (nginx.conf)

This file defines how your application is served and is placed in the conf.d directory.

# nginx.conf (Copied to /etc/nginx/conf.d/nginx.conf)

server {
    # Fixes 'bind() to 0.0.0.0:80 failed (13: Permission denied)'
    # Nginx listens on a high port (>1024) which the non-root user can bind to.
    listen 8080; 
    root /usr/share/nginx/html;
    index index.html;

    # Security: Hides the Nginx version
    server_tokens off;

    # SPA Routing: Directs all unmatched requests to index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Security: Block access to hidden files
    location ~ /\. {
        deny all;
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Local Development with Podman Compose

Using a docker-compose.yaml file automates the entire process.

version: '3.8'

services:
  frontend:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        VITE_API_URL: https:// http://localhost:3000/api/v1

    container_name: frontend-container

    # Maps host's 8080 port to the container's 8080 listening port
    ports:
      - "8080:8080"

    image: my-frontend-app:v1.0.0
    restart: always 
Enter fullscreen mode Exit fullscreen mode

Essential Podman Compose Commands

Command Action
podman compose build Executes the Dockerfile with the specified args.
podman compose up -d Builds (if needed) and starts the container in the background.
podman compose ps Checks the running status of the service(s).
podman compose down Stops and removes the container(s) and network.

4. Guide Reusability and Troubleshooting

This architecture is reusable for nearly any SPA. Just update the VITE_API_URL in docker-compose.yaml and adjust the build script (npm run build:deploy) and output folder (/app/dist) in the Dockerfile if needed.

The most critical errors encountered and resolved are:

Error Logged Cause Solution in this Guide
bind() to 0.0.0.0:80 failed Non-root user attempting to use privileged port 80. Change listen to 8080 in nginx.conf.
open() "/run/nginx.pid" failed Non-root user lacking write permission to the default PID file location. Add pid /var/run/nginx/nginx.pid; to nginx-main.conf.
mkdir() "/var/cache/..." failed Non-root user lacking permission to Nginx cache directory. Add chown -R nginx:nginx /var/cache/nginx to Dockerfile.

You can see the files there with a lot more remarks i made coding-remarks


5. Conclusion

By implementing this Multi-Stage Build and carefully configuring Nginx for a non-root user, you've achieved the trifecta of modern container deployment: security, stability, and speed. Every step—from moving the PID file to changing the listen port—is a direct defense against common production pitfalls and security vulnerabilities. This standardized approach is the most reliable way to ship any modern JavaScript SPA, ensuring your application moves seamlessly from local development to production. You are now equipped with the knowledge to troubleshoot the most common containerization challenges and build a robust, production-ready frontend environment. 🥳

Top comments (0)