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. 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
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:
- If the repo is public, images will be public by default. For private repos, the GITHUB_TOKEN still has permission to push and pull on the server if you log in. Here we’re pulling anonymously; if private, add a docker login step over SSH before compose pull:
- docker login ghcr.io -u
-p - Then set secrets on the server or inject via SSH. Keeping images public for side projects is simpler.
- docker login ghcr.io -u
6) Run the “One‑Click” deploy
- Go to Actions → One‑Click Deploy → Run workflow
- Wait ~1–2 minutes
- Visit http://your.server.ip (or domain if you mapped DNS)
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!





