The ultimate goal for this project is release it as a personal blog starter kit for React developers like Minimal Wordpress.
I'm developping essential feature while my spare time, currenty I planning release v1 2028.
All core feature implemented completely, I'm planning distribute repo source directly as similar as Beam. Roadmap
⚛️ Production ✅ Storybook
Auto post of web page list you read that day.
Used in combination with the browser-extension (included in this monorepo).
- Node.js v22.x.x (managed via Volta)
- pnpm
Install Docker Desktop
git clone https://github.com/laststance/nsx.gitcd nsxpnpm installcp .env.sample .envdocker compose -f compose.yml -f compose.dev.yml up -dpnpm db:resetpnpm validatepnpm server:start- in other terminal screen
pnpm start pnpm e2e:admin- then, you confirmed local develop environment working fine.
open sidebar press x key
DB seeds initial user account is
name: John Doe
pass: popcoon
These are stored in .env and evaluated at build time.
| Variable Name | Description | Required |
|---|---|---|
| VITE_APP_TITLE | Application title displayed in the UI | Yes |
| VITE_APP_DESCRIPTION | Application description for meta tags | Yes |
| VITE_API_ENDPOINT | Backend API endpoint URL | Yes |
| VITE_SENTRY_DSN | Browser Sentry DSN for frontend error tracking | No |
| VITE_SENTRY_DNS | Deprecated typo kept for backward compatibility; use VITE_SENTRY_DSN | No |
| VITE_SENTRY_RELEASE | Browser Sentry release, usually the deployed Git SHA | No |
| VITE_GA_MEASUREMENT_ID | Google Analytics measurement ID (optional) | No |
| ACCESS_TOKEN_SECRET | Secret key for JWT token generation | Yes |
| DATABASE_URL | MySQL database connection string | Yes |
| SENTRY_DSN | Backend Sentry DSN for Express error tracking | No |
| SENTRY_RELEASE | Backend Sentry release, usually the deployed Git SHA | No |
| SENTRY_TRACES_SAMPLE_RATE | Backend Sentry trace sample rate from 0 to 1 | No |
| LOG_LEVEL | JSON logger level (debug, info, warn, error) |
No |
| OPENAI_API_KEY | OpenAI API key for translation features | No |
| BLUESKY_USERNAME | Bluesky account username for posting integration | No |
| BLUESKY_PASSWORD | Bluesky account password for posting integration | No |
| MYSQL_ROOT_PASSWORD | MySQL root password used by Docker Compose and backup jobs | Yes |
| BACKUP_GPG_RECIPIENT | GPG public-key recipient for encrypted production backups | Prod |
| BACKUP_OFFSITE_RSYNC_TARGET | Offsite rsync target such as backup@example.com:/srv/backups/nsx |
Prod |
| BACKUP_ALERT_WEBHOOK_URL | Slack/Discord-compatible webhook called when backup fails | Prod |
- Health check:
GET /api/healthreturns200with DB status or503when MySQL is unreachable. - Metrics:
GET /api/metricsexposes Prometheus text metrics including Node.js defaults and HTTP request duration/counts. - Logs: backend logs are JSON through pino and include
requestId, route, status, and duration. PM2 still writes them tologs/server-out.logandlogs/server-error.log. - Sentry: set
SENTRY_DSNfor Express errors andVITE_SENTRY_DSNfor browser errors. SetSENTRY_AUTH_TOKEN,SENTRY_ORG, andSENTRY_PROJECTin GitHub Actions to upload Vite source maps duringpnpm build. - Uptime alerting: configure an external monitor such as UptimeRobot or Better Stack against
https://nsx.malloc.tokyo/api/healthwith email/Slack/Discord alerts. - PM2 resource alerting: use
pm2 monit,pm2 install pm2-server-monit, or pm2.io, and keepmax_memory_restart: 512Minecosystem.config.jsas the restart guard.
Legacy /api/* endpoints are kept for the current SPA. New integrations should use the versioned /api/v1/* endpoints, which always return a response envelope:
{
"success": true,
"data": {},
"timestamp": "2026-05-27T00:00:00.000Z",
"requestId": "request_123"
}Errors use the same shape with success: false, error, and code, and never expose stack traces or raw internal exception messages. API metadata is available at GET /api/v1/openapi.json.
I'm using Playwright for E2E testing.
Before run pnpm playwright, you need to run pnpm build:e2e.
- commands
pm2 start ecosystem.config.js // Start Server with production mode
pm2 restart ecosystem.config.js // Restart Server with production mode
pm2 stop 0 // Stop server
pm2 ps -a // Show all processes
- Setup Ubuntu server on Digital Ocean or Fly.io
- Update ubuntu with
apt upgrade - see https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-22-04
cd ~ && git clone https://github.com/laststance/nsx.gitcd nsxand install voltasource ~/.bashrc&&volta install nodepnpm- install docker on Ubuntu https://docs.docker.com/engine/install/ubuntu/#set-up-the-repository
docker compose -f compose.yml -f compose.prod.yml up -d(MySQL has no host port; PM2 runtime usesDATABASE_URLwithsocketPath, and Prisma CLI normalizes it for migrations — see.env.sample)- pnpm db:migrate
- touch .env.prod
- npm i -g pm2
touch .env && echo "ACCESS_TOKEN_SECRET=$(openssl rand -base64 60 | tr -d '\n' | cut -c1-60)" >> .envpm2 start ecosystem.config.js- Access from browser
GitHub Repository (main branch)
│
│ Push / PR Merge
▼
GitHub Actions Workflow
│
│ 1. Checkout code
│ 2. Setup Node.js & pnpm
│ 3. Install dependencies
│ 4. Build frontend (Vite) & backend (TypeScript)
│ 5. pnpm deploy: Package server with backend-only deps
▼
Build Artifacts
(build/, server_build/, node_modules/, prisma/)
│
│ Upload via SCP
▼
DigitalOcean Server
│
│ 1. Create .env from secrets
│ 2. Run Prisma migrations
│ 3. PM2 restart (NO pnpm install needed!)
▼
Running Application (https://nsx.malloc.tokyo/)
Note: Production server does NOT run
pnpm install. All backend dependencies are pre-packaged in CI usingpnpm deploy, reducing server load and deployment time.
When adding a new package that the server needs at runtime:
# 1. Add to root (for development)
pnpm add <package-name>
# 2. Also add to server workspace (for production deployment)
pnpm --filter=@nsx/server add <package-name>Why both?
| Location | Purpose | When Used |
|---|---|---|
package.json (root) |
Development | Running nodemon locally |
server/package.json |
Production | pnpm deploy packages only these deps |
💡 pnpm workspace behavior: Individual packages don't have their own
node_modules. All packages share the rootnode_modules. Onlypnpm deploycreates an isolatednode_modulesfor production deployment.
Frontend-only packages (React, UI libraries, etc.):
- Add to root
package.jsononly - No need to add to
server/package.json
NSX includes several utility scripts in the scripts/ directory to help with common development and deployment tasks:
# Deploy both frontend and backend to production
./scripts/deploy
# Deploy only backend
./scripts/deploy -s
# Deploy only frontend
./scripts/deploy -fThe deploy script uses rsync to upload build artifacts to the production server.
# Create, verify, encrypt, rotate, and offsite-sync a production backup
./scripts/backup
# Install the daily 03:00 cron entry on the production server
./scripts/install-backup-cronThe backup job is designed to run on the production server. It dumps MySQL from the Docker container, restores the dump into a temporary verification database, encrypts the verified dump with GPG, keeps 7 daily / 4 weekly / 3 monthly backups, syncs encrypted backups to BACKUP_OFFSITE_RSYNC_TARGET, and sends BACKUP_ALERT_WEBHOOK_URL on failure.
Required production backup settings:
MYSQL_ROOT_PASSWORD=...
BACKUP_GPG_RECIPIENT=backup@nsx
BACKUP_OFFSITE_RSYNC_TARGET=backup@example.com:/srv/backups/nsx
# Optional: needed only when failure/success webhook alerts are desired.
# BACKUP_ALERT_WEBHOOK_URL=https://hooks.slack.com/services/...# Restore a database from a plain, gzipped, or encrypted backup file
./scripts/restore backups/daily/nsx-daily-20260527T030000+0900.sql.gz.gpgThe restore script decrypts/decompresses locally when needed, then streams SQL into the production MySQL Docker container over SSH. Set RESTORE_SSH_TARGET explicitly before every restore so the target host is never guessed.
# Run all validation checks at once
./scripts/validateThe validate script runs tests, linting, type checking, and build in parallel to ensure code quality.
Express binds port 80 (HTTP→HTTPS redirect) and 443 (HTTPS) and loads the Let's Encrypt cert at startup with fs.readFileSync (see server/index.ts). Because certbot uses the standalone authenticator, it also needs port 80 for the ACME challenge — so PM2 must stop before each renewal and start after. The repo ships pre/post hook scripts that automate this:
scripts/letsencrypt-hooks/
├── pre/stop-pm2.sh # certbot pre-hook: pm2 stop server (frees port 80)
└── post/start-pm2.sh # certbot post-hook: pm2 start server (always runs, even on renewal failure)
Deploy to a fresh production server (run once):
scp scripts/letsencrypt-hooks/pre/stop-pm2.sh \
scripts/letsencrypt-hooks/post/start-pm2.sh \
nsx.malloc.tokyo:/tmp/
ssh nsx.malloc.tokyo 'sudo mv /tmp/stop-pm2.sh /etc/letsencrypt/renewal-hooks/pre/ && \
sudo mv /tmp/start-pm2.sh /etc/letsencrypt/renewal-hooks/post/ && \
sudo chown root:root /etc/letsencrypt/renewal-hooks/{pre/stop-pm2.sh,post/start-pm2.sh} && \
sudo chmod 755 /etc/letsencrypt/renewal-hooks/{pre/stop-pm2.sh,post/start-pm2.sh} && \
sudo certbot renew --dry-run'The final --dry-run exercises the full loop (pre → simulated renewal → post) without consuming a Let's Encrypt rate-limit slot.
Manual renewal (recovery, if hooks are missing):
ssh nsx.malloc.tokyo 'pm2 stop server'
ssh nsx.malloc.tokyo 'sudo certbot renew --non-interactive'
ssh nsx.malloc.tokyo 'pm2 start server'Note:
pnpm deployonly rsyncsbuild/,server_build/, andecosystem.config.js. It does NOT update/etc/letsencrypt/renewal-hooks/. Re-run the deploy step above if the hook scripts change.
