Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
node_modules
npm-debug.log
.next
out
.env
.env.*
docker-compose.yml
.git
.gitignore
coverage
.idea
.vscode
ops/backups
32 changes: 32 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Application
APP_HOST=0.0.0.0
APP_PORT=3000
NEXT_PUBLIC_APP_URL=https://nicebaby.local
NODE_ENV=production

# Database
POSTGRES_DB=nicebaby
POSTGRES_USER=nicebaby
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
DATABASE_URL=postgresql://${POSTGRES_USER}:\${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}

# Redis / queue
REDIS_URL=redis://redis:6379
WORKER_HEARTBEAT_INTERVAL=60000
SCHEDULE_INTERVAL=3600000

# Observability
GRAFANA_ADMIN_USER=admin
LOKI_RETENTION_DAYS=7

# Backups
RESTIC_REPOSITORY=s3:s3.amazonaws.com/your-bucket/nicebaby
RESTIC_BACKUP_CRON=0 */6 * * *
RESTIC_KEEP_DAILY=7
RESTIC_KEEP_WEEKLY=4
RESTIC_KEEP_MONTHLY=6

# Reverse proxy
CADDY_EMAIL=admin@nicebaby.local
CADDY_DOMAIN=nicebaby.local
47 changes: 47 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Build Docker Images

on:
push:
branches: [ main ]
pull_request:

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
strategy:
matrix:
image:
- { name: app, dockerfile: Dockerfile.app }
- { name: worker, dockerfile: Dockerfile.worker }
- { name: scheduler, dockerfile: Dockerfile.job }
steps:
- name: Checkout
uses: actions/checkout@v4

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

- name: Log in to registry
if: ${{ secrets.REGISTRY != '' && secrets.REGISTRY_USERNAME != '' && secrets.REGISTRY_PASSWORD != '' }}
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}

- name: Build image
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.image.dockerfile }}
push: ${{ secrets.REGISTRY != '' && secrets.REGISTRY_USERNAME != '' && secrets.REGISTRY_PASSWORD != '' }}
tags: |
${{ secrets.REGISTRY || 'ghcr.io/example' }}/${{ github.repository_owner }}/nicebaby-${{ matrix.image.name }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
NODE_VERSION=20.12.2
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@

# vscode
.vscode

# docker secrets
docker/secrets/*
43 changes: 43 additions & 0 deletions Dockerfile.app
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1.6

ARG NODE_VERSION=20.12.2
FROM node:${NODE_VERSION}-alpine AS base

RUN apk add --no-cache libc6-compat bash wget
WORKDIR /app

FROM base AS deps
COPY package.json package-lock.json* pnpm-lock.yaml* bun.lockb* ./
RUN \
if [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then npm install -g pnpm && pnpm install --frozen-lockfile; \
elif [ -f bun.lockb ]; then npm install -g bun && bun install --frozen-lockfile; \
else npm install; fi

FROM base AS builder
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM base AS runner
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup -g 1001 -S nodejs \
&& adduser -S nextjs -G nodejs

WORKDIR /app
COPY --from=builder /app/next.config.mjs ./
COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1

CMD ["node", "server.js"]
27 changes: 27 additions & 0 deletions Dockerfile.job
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# syntax=docker/dockerfile:1.6

ARG NODE_VERSION=20.12.2
FROM node:${NODE_VERSION}-alpine AS base
RUN apk add --no-cache libc6-compat bash
WORKDIR /app

FROM base AS deps
COPY package.json package-lock.json* pnpm-lock.yaml* bun.lockb* ./
RUN \
if [ -f package-lock.json ]; then npm ci --omit=dev; \
elif [ -f pnpm-lock.yaml ]; then npm install -g pnpm && pnpm install --frozen-lockfile --prod; \
elif [ -f bun.lockb ]; then npm install -g bun && bun install --frozen-lockfile --production; \
else npm install --omit=dev; fi

FROM base AS runner
ENV NODE_ENV=production
RUN addgroup -g 1001 -S nodejs \
&& adduser -S scheduler -G nodejs

WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY scripts ./scripts

USER scheduler
CMD ["node", "scripts/scheduled-job.js"]
27 changes: 27 additions & 0 deletions Dockerfile.worker
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# syntax=docker/dockerfile:1.6

ARG NODE_VERSION=20.12.2
FROM node:${NODE_VERSION}-alpine AS base
RUN apk add --no-cache libc6-compat bash
WORKDIR /app

FROM base AS deps
COPY package.json package-lock.json* pnpm-lock.yaml* bun.lockb* ./
RUN \
if [ -f package-lock.json ]; then npm ci --omit=dev; \
elif [ -f pnpm-lock.yaml ]; then npm install -g pnpm && pnpm install --frozen-lockfile --prod; \
elif [ -f bun.lockb ]; then npm install -g bun && bun install --frozen-lockfile --production; \
else npm install --omit=dev; fi

FROM base AS runner
ENV NODE_ENV=production
RUN addgroup -g 1001 -S nodejs \
&& adduser -S worker -G nodejs

WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY scripts ./scripts

USER worker
CMD ["node", "scripts/worker.js"]
80 changes: 76 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# NiceBaby.io

A self-hosted newborn companion built with Next.js 14. This project scaffolds the front-end experience for tracking feeds,
diapers, sleep, growth, medical appointments, and curated guidance for new parents. It also outlines the surrounding
architecture for API services, data modeling, and Docker-based deployment.
diapers, sleep, growth, medical appointments, and curated guidance for new parents. It now ships with production-grade
Docker images, observability, and backup automation for home-lab and small scale deployments.

## Getting Started
## Getting Started (Local Development)

```bash
npm install
Expand All @@ -13,14 +13,86 @@ npm run dev

Open http://localhost:3000 in your browser to explore the product blueprint.

## Container Images

Three hardened Node.js containers are available:

| Dockerfile | Purpose | Entrypoint |
| --- | --- | --- |
| `Dockerfile.app` | Production Next.js server | `node server.js` |
| `Dockerfile.worker` | Queue/async worker scaffold | `node scripts/worker.js` |
| `Dockerfile.job` | Interval-based scheduled runner | `node scripts/scheduled-job.js` |

Each image:

- Uses Node.js 20 on Alpine with multi-stage builds.
- Drops privileges to an application-specific non-root user.
- Includes health checks and environment parity via `.env` files and Docker secrets.

`.dockerignore` keeps build contexts lean. Customize the worker/scheduler scripts to integrate with your job queue of choice.

## Docker Compose Stack

The repository provides a batteries-included `docker-compose.yml` that orchestrates:

- Next.js app, worker, and scheduler containers
- PostgreSQL 16 with persistent volumes and health checks
- Redis 7 for queues
- Redis Exporter for Prometheus metrics
- Caddy reverse proxy with automatic HTTPS support
- Prometheus, Loki, Promtail, and Grafana for metrics/logs
- Restic-based backup automation for the PostgreSQL volume

### Bootstrap

1. Copy `.env.example` to `.env` and update values.
2. Create the secret files listed in `docker/secrets/README.md`.
3. Launch the stack:
```bash
docker compose build --pull
docker compose up -d
```
4. Verify health at `https://<CADDY_DOMAIN>/api/health` once DNS/TLS propagate.

Grafana auto-loads the sample "Next.js Service Health" dashboard showing request throughput and logs. Prometheus scrapes
`/api/metrics` exposed by the app. Loki receives container logs via Promtail.

## Backups & Restore

The `restic-backup` service snapshots the PostgreSQL volume on the cron defined by `RESTIC_BACKUP_CRON`. Adjust retention
with `RESTIC_KEEP_DAILY`, `RESTIC_KEEP_WEEKLY`, and `RESTIC_KEEP_MONTHLY`. Restoration steps and disaster recovery
checklists live in [`docs/operations.md`](docs/operations.md).

## Home-Lab Deployment Notes

- **Reverse proxy / TLS:** The provided Caddyfile handles automatic TLS via Let's Encrypt. For Traefik users, replicate the
`reverse_proxy` block with middleware for HTTPS redirection and use Docker labels for ACME settings.
- **Firewall hardening:** Expose only ports 80/443 publicly. Limit database/queue/observability ports to your trusted
network or WireGuard peers. Enable UFW/`nftables` rules that default to deny inbound.
- **Secrets management:** Keep secret files on encrypted storage. For Swarm/Kubernetes, translate the secrets into native
secret resources.
- **Backups:** Ensure the Restic repository target is accessible (S3, Backblaze B2, or another NAS). Automate periodic
restore drills to validate credentials and retention policies.

## CI Automation

GitHub Actions builds and caches the three container images on every push. See
[`.github/workflows/docker.yml`](.github/workflows/docker.yml) for details. Publish steps can be extended by defining the
`REGISTRY`, `REGISTRY_USERNAME`, and `REGISTRY_PASSWORD` secrets in your repository.

## Additional Documentation

Operational details, health checks, and restore playbooks are collected in [`docs/operations.md`](docs/operations.md).

## Project Highlights

- **Next.js App Router** foundation with typed routes and Tailwind CSS styling.
- **Component-driven sections** that describe the core modules (tracking, playbooks, deployment) to guide future builds.
- **Data blueprints** (`data/*.ts`) capturing roadmap items for backend, analytics, and self-hosting strategies.
- **Production operations** blueprint with Docker images, observability, backups, and CI automation.

## Next Steps

- Implement authentication, persistence (Prisma + PostgreSQL), and API route handlers for log management.
- Add real tracking forms, dashboards, and background jobs based on the outlined modules.
- Containerize the application with Docker Compose including database, worker, and reverse proxy services.
- Integrate queue processing and scheduled jobs into the provided worker containers.
10 changes: 10 additions & 0 deletions app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
import { trackRequest } from '../metrics/route';

export const runtime = 'nodejs';

export async function GET(request: Request) {
const response = NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
trackRequest(request.method, response.status);
return response;
}
34 changes: 34 additions & 0 deletions app/api/metrics/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import client from 'prom-client';
import { NextResponse } from 'next/server';

export const runtime = 'nodejs';

const register = client.register;

if (!(global as any).__METRICS_DEFAULTS__) {
client.collectDefaultMetrics({ register });
(global as any).__METRICS_DEFAULTS__ = true;
}

let httpRequestCounter = register.getSingleMetric('http_requests_total') as client.Counter | undefined;
if (!httpRequestCounter) {
httpRequestCounter = new client.Counter({
name: 'http_requests_total',
help: 'Count of HTTP requests by status code',
labelNames: ['method', 'status'],
});
}

export async function GET() {
return new NextResponse(await register.metrics(), {
status: 200,
headers: {
'Content-Type': register.contentType,
'Cache-Control': 'no-store',
},
});
}

export function trackRequest(method: string, status: number) {
httpRequestCounter?.inc({ method, status: status.toString() });
}
Loading