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:
Launch checklist
A complete pre-launch checklist to tick off, so you never miss a critical step.
Cost estimator
Estimate roughly what a year of server, domain, and CDN will cost you.
SEO snippet generator
Generate meta tags, robots.txt, sitemap, and other snippets in one click.
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 nginxDockerfile (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-projectSee 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.xmlsshd_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 youruserReload 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_stringNever 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.targetEnable and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myappufw 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 verboseWhat'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 .envis in.gitignoreand 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.