December 25, 2025 · 8 min

One‑Click Deploy of a Side‑Project with Nuxt 4 + TypeScript + Docker

Ship your Nuxt 4 app to a VPS with a single GitHub Action click using a tiny multi‑stage Docker image and Compose.

One‑Click Deploy of a Side‑Project with Nuxt 4 + TypeScript + Docker

nuxt-docker-oneclick

Ship your Nuxt 4 app to a VPS with a single GitHub Action click using a tiny multi‑stage Docker image and Compose. This uses Nuxt 4 (Nitro 2), Node 22 LTS, Docker Buildx, and the latest docker/build-push-action v6.

Tags: Nuxt 4, TypeScript, Docker, GitHub Actions

Time to read: 8 min

Prerequisites

  • A GitHub repository
  • A Linux VPS with Docker and Docker Compose V2 installed
  • Node.js 18.20+ (Node 22 LTS recommended)
  • Basic familiarity with Nuxt and Docker

1) Create a fresh Nuxt 4 + TypeScript app

# Create
npm create nuxt@latest nuxt4-oneclick
cd nuxt4-oneclick

# Install deps
npm install

# Dev server
npm run dev

Open http://localhost:3000 to verify.

Enable strict TypeScript and set an explicit Nitro preset:

// nuxt.config.ts
export default defineNuxtConfig({
  typescript: {
    strict: true
  },
  nitro: {
    // Explicit for clarity; this is the default production preset for Nuxt 4
    preset: 'node-server'
  },
  devtools: { enabled: true }
})

Add a tiny page to confirm the server build works:

<!-- pages/index.vue -->
<script setup lang="ts">
const now = new Date().toLocaleString()
</script>

<template>
  <main class="p-8 font-sans">
    <h1>Nuxt 4 One‑Click Deploy ✅</h1>
    <p>Rendered at: {{ now }}</p>
  </main>
</template>

<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif; }
</style>

2) Production‑grade Dockerfile (multi‑stage, tiny runtime)

Use Node 22 LTS Alpine with a builder and a minimal runtime. This pattern works reliably with Nitro’s output.

# Dockerfile

# ---- Builder ----
FROM node:22-alpine AS builder
ENV NODE_ENV=production
WORKDIR /app

# Install dependencies using a frozen lockfile for repeatability
COPY package*.json ./
RUN npm ci

# Copy sources and build
COPY . .
RUN npm run build

# ---- Runtime ----
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app

# Copy Nitro output
COPY --from=builder /app/.output ./.output

# If Nitro emitted external deps, install them (no-op if not present)
RUN if [ -f .output/server/package.json ]; then \
      cd .output/server && npm ci --omit=dev; \
    fi

EXPOSE 3000
# Nitro reads PORT or NITRO_PORT
ENV PORT=3000

CMD ["node", ".output/server/index.mjs"]

Quick local test:

docker build -t nuxt4-oneclick:local .
docker run --rm -p 3000:3000 nuxt4-oneclick:local
# Visit http://localhost:3000

3) Compose file for the server

We’ll run a single container mapped to port 80 on the VPS. You can put a reverse proxy like Nginx in front later; for simplicity, this binds directly.

# deploy/compose.yaml
name: nuxt-oneclick
services:
  web:
    image: ghcr.io/<OWNER>/<REPO>:latest
    ports:
      - "80:3000"
    environment:
      - NODE_ENV=production
      - PORT=3000
    restart: unless-stopped
    pull_policy: always

Replace / with your org/repo in lowercase (GHCR requires lowercase).

Commit this file:

mkdir -p deploy
git add Dockerfile deploy/compose.yaml nuxt.config.ts pages/index.vue
git commit -m "chore: dockerize and add compose"
git push

4) One‑Click GitHub Action (Build, Push, Deploy over SSH)

The action will:

  • Build a multi‑arch image with Buildx
  • Push to GHCR
  • SSH into your VPS and apply the Compose release

Create .github/workflows/deploy.yml:

name: One-Click Deploy

on:
  workflow_dispatch:

permissions:
  contents: read
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ghcr.io/${{ github.repository }}

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_NAME }}
          tags: |
            type=raw,value=latest
            type=sha

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: true
          platforms: linux/amd64
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_KEY }}
          script_stop: true
          script: |
            set -euo pipefail
            mkdir -p ~/apps/nuxt-oneclick/deploy
            # Write/overwrite compose file from the repo version
            cat > ~/apps/nuxt-oneclick/deploy/compose.yaml <<'YAML'
            name: nuxt-oneclick
            services:
              web:
                image: ${{ env.IMAGE_NAME }}:latest
                ports:
                  - "80:3000"
                environment:
                  - NODE_ENV=production
                  - PORT=3000
                restart: unless-stopped
                pull_policy: always
            YAML
            cd ~/apps/nuxt-oneclick/deploy
            docker compose pull
            docker compose up -d
            docker image prune -f

Notes:

  • The action uses docker/build-push-action v6 and metadata-action v5 (latest stable).
  • pull_policy: always in Compose ensures your server always grabs the fresh image.
  • We deploy the compose from a heredoc; alternatively scp the file, but this keeps the workflow self-contained.

5) Configure secrets and GHCR

In your repository:

  • Settings → Actions → General → Workflow permissions → Read and write permissions (so GITHUB_TOKEN can push to GHCR).
  • Settings → Secrets and variables → Actions → New repository secret:
    • SSH_HOST: your.server.ip.or.hostname
    • SSH_USER: ubuntu (or your Linux user)
    • SSH_KEY: your private key contents (beginning with -----BEGIN OPENSSH PRIVATE KEY-----)

For GHCR visibility:

6) Run the “One‑Click” deploy

You should see “Nuxt 4 One‑Click Deploy ✅”.

7) Optional: domain and TLS (quick notes)

  • Point your domain A/AAAA to the VPS IP.
  • Add an Nginx or Caddy reverse proxy for TLS (e.g., Nginx + certbot, or Caddy with automatic HTTPS).
  • If you add a proxy, change compose port mapping to something like "127.0.0.1:3000:3000" and have the proxy listen on 80/443.

8) Why this is “latest and greatest”

  • Nuxt 4 + Nitro 2: fast server output with minimal runtime and smart externalization
  • Node 22 LTS: modern, stable performance baseline
  • Docker Buildx + build-push-action v6: cache-efficient, reproducible builds with GHCR
  • Compose pull_policy: always for idempotent, one-click redeploys

Troubleshooting

  • Action fails to push image: ensure Workflow permissions → Read and write permissions are enabled.
  • Compose fails with “pull access denied”: either make the GHCR image public or docker login ghcr.io on the server with a PAT that has read:packages.
  • Port already in use: check nginx or another service on port 80 (sudo lsof -i :80). Change ports in deploy/compose.yaml or stop the conflicting service.

Conclusion

You now have a clean, repeatable “click to ship” pipeline:

  • Build Nuxt 4 with strict TypeScript
  • Package with a tiny Node 22 Alpine runtime
  • Push to GHCR
  • Compose pull + up on your VPS via a single GitHub Action run

From idea to internet in one click—happy shipping!