Astro SSR blog and admin panel for publishing articles, managing media, and running a small author workflow on top of Turso and Cloudinary.
This repository is meant to be practical for developers who want to learn from it, run it locally, or adapt it for their own content site.
- Public blog pages rendered with Astro
- Admin dashboard for authors and admins
- JWT cookie-based authentication
- Turso database storage for users, articles, tags, categories, media metadata, and share events
- Cloudinary-based image and video hosting
- Contact form email delivery
- Deployment to a LiteSpeed/LAMP-style shared hosting environment using Node.js and Passenger
Primary author: Jimmy Burns (pluckcode)
Website: https://chipsxp.com
- Astro 5
- TypeScript
- React 19 for interactive admin components
- Tailwind CSS 4
- Turso (
@tursodatabase/serverless) - Cloudinary
josefor JWT signing and verificationbcryptjsfor password hashing- Node.js standalone server output via
@astrojs/node
Before you start, make sure you have:
- Node.js 20 or newer
- npm
- A Turso database
- A Cloudinary account
- A Resend account if you want the contact form to work
- Clone the repository.
- Install dependencies:
npm install- Create your local environment file from the example:
cp .env.example .envIf you are on Windows PowerShell, use:
Copy-Item .env.example .env- Fill in the values in
.env. - Start the development server:
npm run devnpm run dev
npm run build
npm run preview
node --env-file=.env scripts/create-admin.mjs <email> <password>What these do:
npm run devstarts the local Astro dev servernpm run buildcreates the production build indist/npm run previewruns the built app locallycreate-admin.mjscreates your first admin user in the database
Copy .env.example to .env and set the following values.
SITE_URL
Your full site URL, for example https://yourdomain.com
JWT_SECRET
Used to sign login tokens. Generate a strong random value.
Example:
openssl rand -base64 32TURSO_DATABASE_URL
Where to get it: Turso dashboard -> your database -> connection URL
TURSO_AUTH_TOKEN
Where to get it: Turso dashboard -> your database -> auth tokens -> create token
CLOUDINARY_CLOUD_NAME
Where to get it: Cloudinary dashboard -> settings -> account
CLOUDINARY_API_KEY
Where to get it: Cloudinary dashboard -> settings -> API keys / access keys
CLOUDINARY_API_SECRET
Where to get it: Cloudinary dashboard -> settings -> API keys / access keys
RESEND_API_KEY
Where to get it: Resend dashboard -> API Keys
CONTACT_TO_EMAIL
The inbox that should receive contact form messages
CONTACT_FROM_EMAIL
A sender address verified in Resend
PUBLIC_FB_APP_ID
Optional Facebook app ID for social share metadata. Get it from Meta for Developers if you need it.
CDN_UPLOAD_URL
Optional advanced CDN upload endpoint
CDN_PUBLIC_URL
Optional public CDN base URL
CDN_API_KEY
Optional CDN API credential
After you add your .env values, create an admin user:
node --env-file=.env scripts/create-admin.mjs you@example.com "YourStrongPasswordHere"Then run the app and open:
/for the public site/adminfor the login page
At a high level:
- Astro serves the public pages and API routes
- Admin pages use Astro plus React components where interactive UI is needed
- Authentication uses an
auth_tokencookie withHttpOnly,Secure, andSameSite=Strict - Turso stores the application data
- Cloudinary stores uploaded media files
- Resend handles contact form delivery
- Never commit
.env - Never place production secrets in markdown files, screenshots, or sample JSON
- Keep
.env.exampleas placeholders only - The repo is configured to ignore local env files and common secret file formats
- If a secret is ever exposed, rotate it immediately
Create the production build with:
npm run buildThis generates:
dist/client/for static assetsdist/server/entry.mjsfor the Node.js server entrypoint
This project is designed to run on a shared-hosting style setup where:
- LiteSpeed or Apache serves the domain
- cPanel manages the app
- Node.js runs through Passenger / Setup Node.js App
- HTTPS is required because the auth cookie uses the
Secureflag
- Build the app locally with
npm run build - Upload these files to the server:
dist/
package.json
package-lock.json
.nvmrc
- In cPanel, create a Node.js app
- Set the startup file to:
dist/server/entry.mjs
- Add your environment variables in cPanel
- Run npm install on the server
- Restart the Node.js app
- Make sure SSL is active for the domain
Do not upload these to production:
.envsrc/node_modules/- local debug/build backup folders
For the full step-by-step server guide, see docs/DEPLOY-CPANEL.md.
src/
components/ reusable UI
layouts/ page layouts
lib/ database, auth, media helpers
pages/ Astro pages and API routes
scripts/ one-off setup and migration scripts
docs/ deployment and project notes
public/ static assets
If you are new to Astro or full-stack content apps, start in this order:
- Read
package.jsonto see the scripts and dependencies - Read
src/pages/to understand the routes - Read
src/pages/api/to see the backend endpoints - Read
src/lib/db.tsandsrc/lib/auth.tsfor the main app plumbing - Read
docs/DEPLOY-CPANEL.mdwhen you are ready to deploy
- This project uses Astro server output, not a static-only export
- The app expects real service credentials for Turso, Cloudinary, and Resend
- HTTPS is mandatory in production for login to work correctly
First stable release. Core blogging platform, admin workflow, and security baseline are complete.
Core Platform
- Astro 5 SSR with
@astrojs/nodestandalone adapter - Turso (libSQL serverless) database with named-column row normalization
- JWT authentication via HttpOnly
SameSite=Strictcookie — nolocalStoragetokens - Role-based access control:
adminandauthorroles - Quill 2 WYSIWYG editor with toolbar (bold/italic/underline, color, blockquote, code-block, h2/h3, lists, link/image), in-editor image resize via
quill-resize-module
Media Architecture
imagestable — inline editor images stored as base64 payloads, served via/api/inline-images/{id}with immutable caching. On article save, Quill base64 data URIs are auto-extracted and rewritten to stable URLs — authors see no change in the editormediatable — Cloudinary-backed cloud uploads only (images + videos). These two storage systems are strictly separate- Server-side article body guardrails: 1.75 MB body cap, max 4 inline images, 300 KB per inline image, 1.2 MB total inline image bytes per submission
Security Hardening
- HSTS (
max-age=63072000; includeSubDomains) in middleware and.htaccess - Content Security Policy (Report-Only Phase 1): strict hash-based CSP for public routes,
'unsafe-inline'for admin routes - In-memory rate limiting on login (10 fails/IP/15 min), register (5/IP/60 min), contact (5/IP/60 min)
security.checkOrigin: falsewithSameSite=StrictCSRF coverage (required for TLS-terminating proxies on Railway and LiteSpeed)- Middleware two-phase pattern: identity resolution on every route, enforcement only on protected routes
Public Features
- Blog listing and article pages with responsive Cloudinary image delivery
- Open Graph + Twitter Card meta (full
article:*object tags, image dimensions, per-tag entries) - 10-platform social share panel for authors (Facebook, X, Instagram, WhatsApp, Pinterest, Reddit, Threads, LinkedIn, Medium, Bluesky) with per-platform analytics
- Dark/light theme toggle with Golden Age Comics light palette; FOUC-free anti-flash script
- Contact form with Resend email delivery and honeypot protection
- Sitemap at
/sitemap.xml
Admin Dashboard
- Article create / edit / publish / delete with Quill editor
- Media upload panel (Cloudinary) with upload session staging
- Panel gallery with slot assignment (hero, splash)
- User management (admin: promote, suspend; author: own account view)
- Share analytics per article
Infrastructure
- Railway deployment (primary) with Fastly CDN TLS termination
- cPanel / LiteSpeed LAMP deployment (secondary, documented in
docs/DEPLOY-CPANEL.md)
| Version | Feature | Status |
|---|---|---|
| 1.1.0 | Upcoming Events page | Planned |
| 1.2.0 | Art Sales / Shop page | Planned |
| 1.3.0 | (to be determined) | Planned |