Patet

Templates & Checklists

Ready-to-use configs and checklists — copy, tweak the placeholders, ship, instead of typing every config from scratch.

This is the "copy-paste" hub: ready-to-use configs and checklists — copy, tweak the placeholders, ship. Every template is meant to be correct and minimal, with nothing extra to wade through.

Placeholders are consistent throughout: example.com (your domain), /var/www/app (your app directory), youruser (your deploy account). Do a global find-and-replace with your real values before using anything.

Tools

These three interactive tools pair well with the templates below — keep them open while you work:

Copy-paste templates

Each template opens with a sentence or two on what it's for, then a code block you can paste straight in. Swap the placeholders and you're good to go.

Nginx reverse proxy + HTTPS

Reverse-proxy public traffic to an app running locally (here it listens on 127.0.0.1:3000), force HTTPS, and add common security headers plus gzip. The certificate paths assume the defaults from a Certbot auto-issued cert. Pair this with Cloudflare and Security.

# /etc/nginx/sites-available/example.com
# Port 80: 301-redirect everything to HTTPS.
# Redirect to a fixed canonical host (not $host) so a forged Host header
# can't turn this into an open redirect.
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

# Port 443: where the app is actually served
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com www.example.com;

    # Certbot-managed certificate paths
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # gzip compression
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;

    # Reverse proxy to the local app
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        "upgrade";
    }
}

Enable it and reload:

sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Dockerfile (Node app)

A multi-stage build: the first stage installs dependencies and builds, the second copies only what's needed at runtime, keeping the image small. Based on node:20-alpine, runs as a non-root user, and installs production dependencies only.

# ---- build stage ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---- runtime stage ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# Production dependencies only
COPY package*.json ./
RUN npm ci --omit=dev

# Copy build output
COPY --from=builder /app/dist ./dist

# Run as a non-root user (alpine ships a built-in node user)
USER node

EXPOSE 3000
CMD ["node", "dist/server.js"]

docker-compose.yml

One app service plus one Postgres service. Postgres persists data in a named volume and has a healthcheck; the app waits for the database to be ready via depends_on. Sensitive config comes from env_file (the .env below, which you must not commit to Git). The app port is bound to 127.0.0.1 so only your reverse proxy (nginx) can reach it — publishing 3000:3000 would expose it on every interface, and Docker's own iptables rules bypass UFW.

services:
  app:
    build: .
    restart: unless-stopped
    ports:
      # Bind to loopback only; nginx terminates TLS and proxies to this.
      - "127.0.0.1:3000:3000"
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    env_file:
      - .env
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  pgdata:

GitHub Actions deploy

On a push to main, build automatically, then sync the output to your server over SSH + rsync and restart the service. The host, user, private key, and the server's known_hosts line live in the repo's Settings → Secrets and variables → Actions — never in the file itself. Generate the SSH_KNOWN_HOSTS value once locally with ssh-keyscan your.server.com and paste its output into the secret, so the deploy verifies the server's identity instead of blindly trusting whatever key it sees.

name: Deploy

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: npm

      - name: Install & build
        run: |
          npm ci
          npm run build

      - name: Setup SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          # Pin the server's host key (set this secret with `ssh-keyscan your.server.com`)
          echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
          chmod 644 ~/.ssh/known_hosts

      - name: Deploy with rsync
        run: |
          # Sync into dist/ (not the app root) so --delete can't remove .env,
          # and the path matches the systemd ExecStart below.
          rsync -az --delete ./dist/ \
            "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/app/dist/"
          ssh "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
            "sudo systemctl restart myapp"

Don't want to touch a server? Use Cloudflare Pages

For a static site or a full-stack framework, skip SSH entirely and deploy to Cloudflare Pages. Replace the Deploy step above with:

      - name: Deploy to Cloudflare Pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy ./dist --project-name=my-project

See Cloudflare for details.

robots.txt

Drop this at your site root to tell search-engine crawlers they can crawl everything, and point them at your sitemap. For a richer version, use the SEO snippet generator.

User-agent: *
Allow: /

Sitemap: https://example.com/sitemap.xml

sshd_config hardening snippet

Disable direct root login and password login, and allow key-based auth only — this is the single most important step for server security. After changing it, test a new session with your key before closing the old one so you don't lock yourself out. For fuller hardening, see Security.

# /etc/ssh/sshd_config
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
X11Forwarding no
MaxAuthTries 3
LoginGraceTime 20
# Restrict logins to specific users (optional)
AllowUsers youruser

Reload SSH after editing:

sudo sshd -t && sudo systemctl reload ssh

.env.example

Commit this file to Git as a "field guide" so collaborators know which variables they need; the real .env with secrets must never be committed — add it to .gitignore.

# Copy to .env and fill in real values
# Note: .env contains secrets — add it to .gitignore and never commit it!

NODE_ENV=production
PORT=3000

# Database connection string
DATABASE_URL=postgres://youruser:CHANGE_ME@localhost:5432/myapp

# Session / JWT secret — use a long random string: openssl rand -hex 32
SESSION_SECRET=CHANGE_ME_to_a_long_random_string

Never commit the real .env to Git

Once a secret lands in Git history, deleting it later doesn't help — it's already public. Add .env to .gitignore before your very first commit.

systemd service

Run your Node app as a system service so it starts on boot, restarts automatically on crash, and runs as a non-root user. Environment variables are loaded from EnvironmentFile (the .env above).

# /etc/systemd/system/myapp.service
[Unit]
Description=My Node App
After=network.target

[Service]
Type=simple
User=youruser
Group=youruser
WorkingDirectory=/var/www/app
EnvironmentFile=/var/www/app/.env
ExecStart=/usr/bin/node /var/www/app/dist/server.js
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Enable and start it:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp

ufw firewall setup

Least-privilege by default: allow only SSH, HTTP, and HTTPS, deny everything else. Allow SSH before you enable, or you risk locking yourself out. For the full strategy, see Security.

# Deny all inbound by default, allow all outbound
sudo ufw default deny incoming
sudo ufw default allow outgoing

# Allow the ports you need
sudo ufw allow OpenSSH      # 22
sudo ufw allow 80/tcp       # HTTP
sudo ufw allow 443/tcp      # HTTPS

# Enable and check status
sudo ufw enable
sudo ufw status verbose

What's next

Templates are just a starting point. Each topic gets a full walkthrough in its own guide:

  • Server security (SSH, firewall, backups, monitoring) → Security
  • DNS, CDN, caching, and basic protection → Cloudflare
  • Getting found by search engines → SEO

Checklist for this step

  • Every placeholder (example.com, /var/www/app, youruser) is replaced with your real values
  • .env is in .gitignore and no secrets are committed to Git
  • Deploy secrets (SSH private key, API tokens) live in the platform, not hard-coded in repo files
  • After editing sshd_config, you verified a new session can log in before closing the old one
  • You allowed SSH before enabling ufw, so you're not locked out

See the full launch checklist.

On this page