Deploying a Full-Stack Next.js Application to Production
Shipping an application is one thing. Keeping it available, secure, and automatically updated is another challenge entirely. On localhost, your machine handles every dependency. On a production server, nothing is pre-configured — there is no runtime, no database, no SSL, no process manager.
This guide covers every step needed to take a full-stack Next.js application from a local machine to a hardened production server with HTTPS, automated deployments, a PostgreSQL database, Redis caching, and Nginx as a reverse proxy.
The stack covered in this guide:
- Next.js 16 with
output: "standalone"for lean production builds - Docker to containerise every service
- PostgreSQL for persistent data storage
- Redis for sessions and caching
- Nginx as a reverse proxy with SSL termination
- Let's Encrypt for free, auto-renewing SSL certificates
- GitHub Actions for automated CI/CD
- Prisma 7 for schema migrations
Every decision in this guide comes from a real production deployment of a university fee management system — including the mistakes and the fixes.
Prerequisites: Linux Basics and Networking
Before provisioning a server, get comfortable with a handful of essential concepts. These commands and ideas will appear constantly throughout this guide.
Essential Linux Commands
ls -la
Lists all files and directories, including hidden ones. The -la flags reveal permissions and ownership.
cd ~/myproject
Navigates into a directory. The ~ tilde is shorthand for your home folder.
nano filename
Opens a file for editing directly in the terminal. Ctrl+O saves, Ctrl+X exits.
sudo command
Executes a command as the system administrator. Required for installing software, modifying system files, and managing services.
journalctl -u docker --tail 50
Streams the last 50 log lines for a service. Indispensable for diagnosing issues in production.
File Permissions
Every file on Linux carries permissions that govern who can read, write, or execute it.
chmod 600 ~/.ssh/my-key.pem
SSH keys must have restricted permissions. If the file mode is too permissive, SSH will refuse to use the key and the connection will fail.
Networking Concepts
Every server has a public IP address. Registering a domain name means creating a DNS record that maps that name to the IP. When a browser resolves the domain, it connects to port 443 for HTTPS or port 80 for HTTP.
| Port | Protocol | Purpose | |------|----------|---------| | 22 | TCP | SSH access | | 80 | HTTP | Web traffic / Let's Encrypt verification | | 443 | HTTPS | Encrypted web traffic | | 5432 | TCP | PostgreSQL (internal only) | | 6379 | TCP | Redis (internal only) |
Setting Up Your Server
Choosing a Server
This guide uses AWS EC2, but every concept transfers directly to any VPS provider — DigitalOcean, Hetzner, Vultr, or Linode. The setup is identical once there is a fresh Ubuntu 24.04 server with an assigned IP address.
Without a static (Elastic) IP, the server receives a new address on every restart. Any domain pointing to the old IP stops resolving immediately.
SSH Configuration
Rather than typing the full SSH command every time, create a config file at ~/.ssh/config on the local machine:
Host myserver
HostName YOUR_SERVER_IP
User ubuntu
IdentityFile ~/.ssh/your-key.pem
This reduces the connection to a single, memorable command:
Firewall and Security Groups
For a web application these are the required inbound rules:
| Port | Source | Purpose | |------|-----------|---------| | 22 | Anywhere | SSH access for you and GitHub Actions | | 80 | Anywhere | HTTP for Let's Encrypt and redirects | | 443 | Anywhere | HTTPS for your application |
Ports 5432 (PostgreSQL) and 6379 (Redis) must never appear in public firewall rules. Both services communicate exclusively over internal Docker networks.
Initial Server Configuration
Create a non-root user immediately. Operating entirely as root is a significant security risk — a single mistyped command can cause irreversible system damage.
sudo adduser noman sudo usermod -aG sudo noman sudo usermod -aG docker noman
Adding Swap Memory
Cloud servers often have constrained RAM. Building a Node.js application demands far more memory than running one. Without swap space, the build process is killed mid-flight and the server becomes unresponsive.
Add 2 GB of swap before doing anything else:
sudo fallocate -l 2G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
The final line persists swap across reboots. Without it, the swap partition vanishes every time the server restarts.
Docker
The Problem Docker Solves
Development machines carry specific versions of Node.js, PostgreSQL, and Redis. A production server may have different versions installed, missing system libraries, or conflicting configurations.
Docker packages the application and every dependency into a self-contained unit called a container. That container runs identically on a development machine, a colleague's laptop, and a production server — regardless of what is installed on the host.
Installing Docker
sudo apt update sudo apt install -y docker.io docker-compose-plugin sudo systemctl enable docker sudo systemctl start docker
Verify the installation:
Images vs Containers
A Docker image is a blueprint — application code, the runtime, and all dependencies frozen at a specific point in time. An image does not execute; it is a template.
A Docker container is a live, running instance of an image. Multiple containers can be spawned from the same image simultaneously.
Think of an image as a recipe and a container as the dish prepared from it.
Key Docker commands:
docker images # list all local images docker ps # list running containers docker ps -a # all containers (including stopped) docker logs container_name # view container output docker exec -it container_name sh # open shell inside container docker system prune -f # free up disk space
A Dockerfile is a plain text file that defines how a Docker image is built. It lives at the root of your project alongside package.json. Docker reads it top-to-bottom and executes each instruction to produce a final image.
Every instruction adds a layer to the image. The FROM instruction sets the base operating system. COPY brings in your application files. RUN executes shell commands. CMD defines what process starts when a container launches.
Multi-Stage Builds
A single-stage Dockerfile has a size problem. Building a Next.js application pulls in TypeScript, ESLint, and hundreds of megabytes of development dependencies — none of which are required to serve the application.
Multi-stage builds eliminate this: the Dockerfile defines several named FROM stages, and the final stage only contains what is explicitly copied from earlier ones. Build tooling and dev dependencies are discarded entirely. The result is a production image that can be 70–80% smaller than a naive single-stage build.
FROM node:20-alpine AS base # ── Stage 1: Install all dependencies ──────────────────────────────────────── FROM base AS deps RUN apk add --no-cache libc6-compat WORKDIR /app COPY package.json package-lock.json ./ COPY prisma ./prisma/ RUN npm ci # ── Stage 2: Build the application ─────────────────────────────────────────── FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY prisma ./prisma/ COPY . . # Dummy values satisfy module checks during build. # Real values come from .env at runtime — these are never used. ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder" ENV REDIS_URL="redis://placeholder:6379" ENV JWT_SECRET="placeholder-secret-for-build-only" ENV NEXTAUTH_SECRET="placeholder-secret-for-build-only" ENV NEXT_PUBLIC_APP_URL="https://yourdomain.com" ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_placeholder" ENV STRIPE_SECRET_KEY="sk_test_placeholder" ENV STRIPE_WEBHOOK_SECRET="whsec_placeholder" ENV SENDGRID_API_KEY="SG.placeholder" ENV FROM_EMAIL="noreply@yourdomain.com" RUN npx prisma generate ENV NEXT_TELEMETRY_DISABLED=1 RUN NODE_OPTIONS="--max-old-space-size=1536" npm run build # ── Stage 3: Production image ───────────────────────────────────────────────── FROM base AS runner RUN apk add --no-cache dumb-init WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=3000 ENV HOSTNAME=0.0.0.0 ENV HOME=/home/nextjs RUN mkdir -p /home/nextjs RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 --ingroup nodejs \ --home /home/nextjs --shell /bin/false nextjs COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/app/generated ./app/generated COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./prisma.config.ts RUN npm install prisma@7.8.0 RUN chown -R nextjs:nodejs /app && chown -R nextjs:nodejs /home/nextjs USER nextjs EXPOSE 3000 HEALTHCHECK \ --interval=30s \ --timeout=10s \ --start-period=60s \ --retries=3 \ CMD node -e "require('http').get('http://127.0.0.1:3000', \ (res) => process.exit(res.statusCode < 500 ? 0 : 1)).on('error', () => process.exit(1))" CMD ["dumb-init", "node", "server.js"]
Node.js does not handle Unix signals correctly when running as PID 1 inside a container. dumb-init acts as a minimal init process, forwarding signals properly and ensuring docker stop triggers a graceful shutdown.
next.config.ts: The Standalone Output
Configure Next.js for standalone output before building the Docker image:
import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", typescript: { ignoreBuildErrors: true, }, eslint: { ignoreDuringBuilds: true, }, }; export default nextConfig;
Without output: "standalone", a production Next.js build requires the entire node_modules folder to run. Standalone mode produces a self-contained server.js with only the runtime dependencies it actually uses.
Building and Pushing the Image
Never build a Next.js application directly on the production server. Limited RAM will exhaust memory mid-build and freeze the machine. Build the image locally, push it to Docker Hub, and pull the finished image on the server.
docker login docker build -t yourdockerhubusername/your-app:latest . docker push yourdockerhubusername/your-app:latest
Docker Volumes
By default, any file written inside a container exists only for as long as that container runs. When the container is removed or replaced, the data is gone. This is fine for stateless application code — it is fatal for a database.
A Docker volume is a storage mechanism managed by Docker itself, existing outside the container filesystem on the host machine. The container writes to a path inside itself, but Docker transparently redirects those writes to the named volume, which persists independently of any container lifecycle.
The Problem
Containers are ephemeral by design. Stopping or replacing a container discards everything inside it. For application code, this is intentional. For a database, it is catastrophic.
If PostgreSQL stores its data inside the container filesystem and the container is replaced during a deployment, every user record and every transaction is gone permanently.
How Named Volumes Solve This
Docker named volumes live outside the container on the host machine. Writes go through a Docker-managed path that persists independently of any container lifecycle.
docker volume ls # list all volumes docker volume inspect myproject_postgres_data # inspect a volume
Never run docker compose down -v on a production server. The -v flag removes all volumes and permanently destroys all database data with no recovery path.
Bind Mounts vs Named Volumes
| Type | Use For | Why | |------|---------|-----| | Named volumes | PostgreSQL, Redis data | Managed by Docker, survive container replacements | | Bind mounts | Nginx config files | Edit directly on host without rebuilding images |
Docker Networks
The Problem: Containers Cannot Talk to Each Other by Default
Each container is isolated. By default, a Next.js container has no way to reach a PostgreSQL container even if both are running on the same host. To communicate, containers must be explicitly placed on a shared network.
Beyond connectivity, there is a security concern. In a naive setup, a single compromised service could potentially reach every other service. A database should never be reachable from the internet — yet without deliberate network separation, the firewall alone is not enough.
How Docker Networks Solve This
Docker networks create isolated communication channels between containers. Containers on the same network can reach each other by their service name — no IP addresses needed. Containers on different networks are completely isolated.
This setup uses two separate networks:
backend_network— connects PostgreSQL, Redis, and Next.js. Nothing outside this network can access the database.frontend_network— connects Next.js and Nginx. Nginx accepts internet traffic and forwards it to Next.js internally.
Even if Nginx were compromised, an attacker still cannot reach the database. The network boundary enforces this at the infrastructure level.
Port Mapping
The ports key maps a host port to a container port. Format is host_port:container_port.
- Nginx receives ports
80and443— it is the only service that accepts internet traffic - Next.js has no port mapping — reachable only through Nginx on the internal network
- PostgreSQL and Redis have no port mapping — internal only, unreachable from outside
Docker Compose
Managing five separate docker run commands — each with its own flags, networks, and volume mounts — is impractical and error-prone. Docker Compose solves this by letting you define the entire application stack in a single declarative file.
The file is named docker-compose.yml (or compose.yml) and lives at the root of your project. Running docker compose up reads the file, creates all the defined networks and volumes, and starts every service in dependency order.
Each service in the file corresponds to one container. Services can reference each other by name — this is why the database URL uses postgres as the hostname rather than an IP address. Docker resolves the service name to the correct container automatically.
The Complete Stack Definition
services: postgres: image: postgres:16-alpine container_name: postgres_db restart: unless-stopped environment: POSTGRES_DB: ${DB_NAME} POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data networks: - backend_network healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] interval: 10s timeout: 5s retries: 5 start_period: 30s redis: image: redis:7-alpine container_name: redis_cache restart: unless-stopped command: redis-server --requirepass ${REDIS_PASSWORD} volumes: - redis_data:/data networks: - backend_network healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 10s timeout: 5s retries: 5 nextjs: image: yourdockerhubusername/your-app:latest container_name: nextjs_app restart: unless-stopped env_file: - .env environment: NODE_ENV: production DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME} REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 networks: - backend_network - frontend_network depends_on: postgres: condition: service_healthy redis: condition: service_healthy nginx: image: nginx:alpine container_name: nginx_proxy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/ssl:/etc/nginx/ssl:ro - certbot_data:/var/www/certbot networks: - frontend_network depends_on: nextjs: condition: service_healthy certbot: image: certbot/certbot:latest container_name: certbot volumes: - ./nginx/ssl:/etc/letsencrypt - certbot_data:/var/www/certbot volumes: postgres_data: driver: local redis_data: driver: local certbot_data: driver: local networks: backend_network: driver: bridge frontend_network: driver: bridge
Key Docker Compose commands:
docker compose up -d # start all services (detached) docker compose ps # check status and healthchecks docker compose logs -f nextjs # follow live logs docker compose pull nextjs # pull latest image without restarting docker compose up -d --force-recreate nextjs # restart only Next.js docker compose exec nextjs sh # open shell in running container
condition: service_healthy guarantees containers start in the correct order. Next.js will not launch until PostgreSQL and Redis pass their healthchecks, eliminating connection errors on startup.
Nginx as Reverse Proxy
Next.js listens on port 3000. Browsers expect HTTPS on port 443. Nginx bridges that gap.
A reverse proxy sits in front of your application server. It accepts all incoming internet traffic and forwards it to the appropriate internal service. Clients never communicate with Next.js directly — they only ever see Nginx.
Beyond routing, Nginx handles several responsibilities that Next.js should not own:
- SSL termination — decrypts HTTPS traffic so Next.js always receives plain HTTP internally
- Rate limiting — limits requests per IP to protect against brute force and abuse
- Gzip compression — compresses responses before they leave the server, reducing bandwidth by up to 80%
- Security headers — adds
X-Frame-Options,X-Content-Type-Options, and similar headers automatically - Static asset caching — tells browsers to cache
/_next/static/files for one year
The configuration lives in nginx/nginx.conf at the root of your project. It is mounted into the Nginx container as a read-only bind mount, so editing the file and restarting Nginx applies changes instantly — no image rebuild needed.
The Full Nginx Configuration
events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; # Security: hide Nginx version from response headers server_tokens off; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; # Compression: CSS/JS/JSON compress ~70-80% gzip on; gzip_min_length 1024; gzip_types text/plain text/css application/javascript application/json; # Rate limiting by IP address limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=api:10m rate=5r/s; upstream nextjs_upstream { server nextjs:3000; keepalive 32; # reuse connections — significantly reduces latency } # HTTP → HTTPS redirect + Let's Encrypt challenge server { listen 80; server_name yourdomain.com www.yourdomain.com; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } } # HTTPS server server { listen 443 ssl; server_name yourdomain.com www.yourdomain.com; ssl_certificate /etc/nginx/ssl/live/yourdomain.com/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/live/yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; # General pages — 10 req/s, burst of 20 location / { proxy_pass http://nextjs_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; limit_req zone=general burst=20 nodelay; } # Static assets — aggressive cache (1 year, immutable) location /_next/static/ { proxy_pass http://nextjs_upstream; add_header Cache-Control "public, immutable, max-age=31536000"; } # Webhooks — MUST come before /api/ — no rate limiting location /api/webhooks/ { proxy_pass http://nextjs_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 30s; } # API routes — 5 req/s (more expensive — hit the database) location /api/ { proxy_pass http://nextjs_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 120s; limit_req zone=api burst=10 nodelay; } } }
Without these headers, every request appears to originate from Nginx's internal IP. Rate limiting breaks because all clients look identical. Authentication callbacks produce HTTP URLs instead of HTTPS. Always forward Host, X-Real-IP, and X-Forwarded-Proto on every location block.
The SSL Certificate Bootstrap Problem
There is a classic bootstrap problem here: Nginx needs an SSL certificate to start. The certificate cannot be issued until Let's Encrypt verifies domain ownership. Let's Encrypt performs that verification over HTTP through Nginx.
The solution is a deliberate two-stage approach:
Step 1 — Start with an HTTP-only config:
events { worker_connections 1024; } http { upstream nextjs_upstream { server nextjs:3000; } server { listen 80; server_name yourdomain.com www.yourdomain.com; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { proxy_pass http://nextjs_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } } }
Step 2 — Get the certificate:
docker compose up -d nginx docker compose run --rm certbot certonly \ --webroot \ --webroot-path /var/www/certbot \ --email your@email.com \ --agree-tos \ --no-eff-email \ -d yourdomain.com \ -d www.yourdomain.com
Step 3 — Restore the full SSL config and restart:
git checkout nginx/nginx.conf docker compose restart nginx
Environment Variables and Secrets
Hard-coding database passwords, API keys, or domain names directly into source code is a critical security vulnerability. Anyone with access to the repository has access to those credentials.
Environment variables solve this by separating configuration from code. The application reads sensitive values at runtime from the environment it is running in. The same Docker image can connect to a staging database or a production database — depending entirely on what is in the .env file on that server.
The .env file is a plain text file with one KEY=VALUE pair per line. It lives at the root of the project on the server only — it must never be committed to version control. Docker Compose reads it automatically when a service declares env_file: - .env.
Create the .env file directly on the server — never commit it to source control:
DB_NAME=yourapp DB_USER=yourapp_user DB_PASSWORD=StrongPasswordHere DATABASE_URL=postgresql://yourapp_user:StrongPasswordHere@postgres:5432/yourapp REDIS_PASSWORD=AnotherStrongPassword REDIS_URL=redis://:AnotherStrongPassword@redis:6379 JWT_SECRET=generate-with-openssl-rand-base64-64 JWT_EXPIRES_IN=7d SENDGRID_API_KEY=SG.your_actual_key FROM_EMAIL=noreply@yourdomain.com NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_your_key STRIPE_SECRET_KEY=sk_live_your_key STRIPE_WEBHOOK_SECRET=whsec_your_secret NEXT_PUBLIC_APP_URL=https://yourdomain.com NODE_ENV=production
Generate a cryptographically strong JWT secret:
Add to .gitignore:
.env
.env.*
*.env
nginx/ssl/
node_modules/
.next/
Commit a .env.example file with identical keys but empty values. Anyone onboarding to the project immediately knows which variables are required without exposing real credentials.
Prisma 7 in Production
Prisma is a TypeScript ORM (Object-Relational Mapper) that provides a type-safe interface for interacting with PostgreSQL. Instead of writing raw SQL, you define your schema in prisma/schema.prisma and Prisma generates a strongly-typed client.
Prisma Migrate manages database schema changes through versioned migration files stored in prisma/migrations/. Running prisma migrate deploy applies any pending migrations to the database in sequence.
Prisma 7 introduced a breaking change: the database connection URL moved from schema.prisma to a dedicated prisma.config.ts file.
// prisma.config.ts (at project root) import "dotenv/config"; import { defineConfig, env } from "prisma/config"; export default defineConfig({ schema: "prisma/schema.prisma", migrations: { path: "prisma/migrations", }, datasource: { url: env("DATABASE_URL"), }, });
The import "dotenv/config" line at the top is non-negotiable. Without it, process.env.DATABASE_URL resolves to undefined even when the value exists in .env.
Running Migrations with a Separate Container
The Next.js standalone build excludes the Prisma CLI — it ships only what is required to serve web requests. Run migrations using a temporary container instead:
docker run --rm \ --network myproject_backend_network \ -v ~/myproject/prisma:/app/prisma \ -v ~/myproject/prisma.config.ts:/app/prisma.config.ts \ --env-file ~/myproject/.env \ -w /app \ node:20-alpine \ sh -c "npm install prisma@7.8.0 dotenv --no-save && npx prisma migrate deploy"
Re-run this command for every new migration that needs to be applied to production.
Pushing Code to GitHub
git init git add . git commit -m "initial commit" git branch -M main git remote add origin https://github.com/yourusername/your-repo.git git push -u origin main
GitHub Actions CI/CD
CI/CD (Continuous Integration / Continuous Deployment) is the practice of automatically building, testing, and deploying code every time a change is pushed to the repository.
GitHub Actions is a built-in automation platform on GitHub. Workflows are defined as YAML files inside .github/workflows/ at the root of your project. Each workflow specifies the trigger (e.g. a push to main), the machine to run on (ubuntu-latest), and a sequence of steps.
The deployment workflow here does four things in order:
- Builds the Docker image from the latest code
- Pushes it to Docker Hub
- SSHes into the production server
- Pulls the new image and restarts only the Next.js container — zero downtime for the database and Nginx
This means every git push origin main automatically deploys to production without any manual steps.
The Deployment Workflow
Create .github/workflows/deploy.yml:
name: Build and Deploy on: push: branches: - main jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and Push Docker image run: | docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/your-app:latest . docker push ${{ secrets.DOCKERHUB_USERNAME }}/your-app:latest - name: Deploy to server uses: appleboy/ssh-action@master with: host: ${{ secrets.SERVER_IP }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd ~/myproject git pull origin main docker compose pull nextjs docker compose up -d --force-recreate nextjs
GitHub Secrets to Configure
Navigate to Repository Settings → Secrets and variables → Actions and add the following:
| Secret | Value |
|--------|-------|
| DOCKERHUB_USERNAME | Your Docker Hub username |
| DOCKERHUB_TOKEN | Docker Hub access token (Read & Write) |
| SERVER_IP | Your server IP address |
| SERVER_USER | Your server username (e.g. noman) |
| SSH_PRIVATE_KEY | Full contents of your .pem file |
Get your SSH key contents:
GitHub Actions uses rotating IP addresses. Restricting SSH to a single home IP will break automated deployments. The private key still protects the server — port 22 alone grants nothing without it.
The Complete Deployment Flow
On Your Local Machine (once)
- Configure
output: "standalone"innext.config.ts - Write your
Dockerfilewith multi-stage build - Write
docker-compose.ymldefining all services - Write
nginx/nginx.conf - Create
.github/workflows/deploy.yml - Build:
docker build -t yourusername/your-app:latest . - Push:
docker push yourusername/your-app:latest - Push code:
git push origin main
On Your Server (one-time setup)
# Install Docker sudo apt update && sudo apt install -y docker.io docker-compose-plugin # Add swap sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile sudo mkswap /swapfile && sudo swapon /swapfile # Clone repository git clone https://github.com/yourusername/your-repo.git myproject cd myproject # Create environment file nano .env # Get SSL certificate (use HTTP-only nginx.conf first) docker compose up -d nginx docker compose run --rm certbot certonly --webroot ... # Restore SSL config and start everything git checkout nginx/nginx.conf docker compose pull docker compose up -d # Run migrations docker run --rm --network myproject_backend_network ...
Every Subsequent Deployment
git add . git commit -m "your change description" git push origin main # GitHub Actions handles the rest automatically
Architecture Overview
Internet
│
▼
Server Firewall (ports 80, 443 open)
│
▼
Nginx Container
→ HTTP:80 redirects to HTTPS
→ SSL termination (Let's Encrypt)
→ Rate limiting per IP
→ Security headers
→ Gzip compression
→ Forwards to Next.js
│
▼
Next.js Container (port 3000, internal only)
→ Serves pages and API routes
→ Reads .env at runtime
│
├──▶ PostgreSQL Container
│ → Named volume: postgres_data
│ → Only reachable on backend_network
│
└──▶ Redis Container
→ Named volume: redis_data
→ Only reachable on backend_network
Common Problems and Fixes
| Problem | Cause | Fix |
|---------|-------|-----|
| Server freezes during build | Out of memory | Add 2GB swap before building |
| Nginx won't start (SSL mode) | Certificate doesn't exist yet | Use HTTP-only config first, get cert, then restore |
| Database data gone after restart | Ran docker compose down -v | Never use -v in production |
| prisma generate fails in Docker | Missing ENV vars at build time | Add dummy placeholder ENV values in builder stage |
| prisma migrate deploy fails in Next.js container | CLI not in standalone build | Use separate docker run container for migrations |
| GitHub Actions SSH timeout | Firewall restricts port 22 to your IP | Open port 22 to Anywhere — key still protects server |
| App can't connect to database | Using localhost instead of service name | Use postgres as hostname in DATABASE_URL |
Production deployment is a set of specific problems with specific, learnable solutions. Master each layer in isolation and the full stack becomes straightforward.