https://github.com/dporkka/100y-saas
SaaS template designed to last 100 years with minimal mainenance
https://github.com/dporkka/100y-saas
100-year durable full-stack future-proof minimal minimalist no-dependencies no-maintenance resilient sqlite
Last synced: 4 months ago
JSON representation
SaaS template designed to last 100 years with minimal mainenance
- Host: GitHub
- URL: https://github.com/dporkka/100y-saas
- Owner: dporkka
- License: other
- Created: 2025-09-26T14:47:52.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2025-09-26T16:19:42.000Z (4 months ago)
- Last Synced: 2025-09-26T16:29:01.025Z (4 months ago)
- Topics: 100-year, durable, full-stack, future-proof, minimal, minimalist, no-dependencies, no-maintenance, resilient, sqlite
- Language: Go
- Homepage: https://github.com/dporkka/100y-saas
- Size: 49.8 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Funding: .github/FUNDING.yml
- License: LICENSE
Awesome Lists containing this project
README
# 100‑Year SaaS: Production-Ready Minimalist SaaS Platform
> A tiny, boring-on-purpose stack designed to keep running for decades with zero maintenance
[](https://github.com/dporkka/100y-saas/actions)
[](https://goreportcard.com/report/github.com/dporkka/100y-saas)
[](https://hub.docker.com/r/100y-saas)
[](LICENSE)
**100‑Year SaaS** is a complete, production-ready multi-tenant SaaS platform built for extreme durability and minimal maintenance. It includes authentication, analytics, subscriptions, background jobs, rate limiting, and email notifications—all using only SQLite and Go's standard library.
## ✨ Features
### 🚀 **Complete SaaS Platform**
- **Multi-tenant Architecture** - Complete data isolation between organizations
- **User Authentication** - Registration, login, session management with auto-cleanup
- **Subscription Management** - Free/paid tiers with automatic usage limit enforcement
- **Real-time Analytics** - Usage tracking, reporting, dashboard stats
- **Background Jobs** - SQLite-based job queue with retries and scheduling
- **Rate Limiting** - In-memory token bucket algorithm with auto-cleanup
- **Email Notifications** - SMTP-based emails with template system
- **Health Monitoring** - `/healthz` endpoint for uptime monitoring
### 🏗️ **Zero-Maintenance Design**
- **Single Binary** - Everything embedded in one executable
- **Single Database** - All data in one SQLite file
- **No External Services** - Redis, Postgres, or cloud services required
- **Self-Healing** - Automatic cleanup, retries, and maintenance
- **Complete Data Ownership** - No vendor lock-in or data sharing
## 📋 Quick Start
### Option 1: One-Click Install (Linux/macOS)
```bash
# Clone and install with automatic Go setup and systemd service
curl -fsSL https://raw.githubusercontent.com/dporkka/100y-saas/main/install.sh | sudo bash -s -- --domain=yourdomain.com
```
### Option 2: Docker Compose (Recommended for Development)
```bash
git clone https://github.com/dporkka/100y-saas.git
cd 100y-saas
# Copy and customize environment variables
cp example.env .env
# Edit .env with your settings
# Start with Caddy reverse proxy
docker-compose up -d
# Load demo data (optional)
./examples/load_demo_data.sh
```
### Option 3: Build from Source
```bash
# Install Go 1.22.5+
git clone https://github.com/dporkka/100y-saas.git
cd 100y-saas
# Install dependencies and build
go mod tidy
go build -o bin/app ./cmd/server
# Run locally
DB_PATH=data/app.db APP_SECRET=local-secret ./bin/app
```
## 🌐 Demo
Try it live at **https://demo.100y-saas.com** (if available)
**Demo Accounts:**
- `demo@example.com` / `hello` (Acme Corporation - Pro Plan)
- `admin@example.com` / `admin` (Tech Startup - Starter Plan)
- `user@example.com` / `secret` (Freelancer - Free Plan)
## High‑Level Architecture
```
[Browser]
│ Static HTML/CSS/JS (no toolchains)
▼
[Caddy] — TLS + reverse proxy (auto HTTPS)
▼
[Go HTTP binary]
├─ Routes: /, /api/*, /export
├─ Auth: signed cookies (HMAC) or HTTP Basic (optional)
└─ Migrations: run once at boot from embedded SQL
▼
[SQLite file]
└─ ACID, single file backups
```
**Principles**
* Protocols over frameworks (HTTP, HTML, SQL)
* Single static binary for the app (Go)
* Single-file database (SQLite)
* Files > services; cron > orchestration
* Everything exportable (CSV/JSON)
---
## Repo Layout
```
100y-saas/
├─ cmd/
│ └─ server/
│ └─ main.go
├─ internal/
│ ├─ db/
│ │ ├─ schema.sql
│ │ └─ queries.go
│ ├─ http/
│ │ ├─ handlers.go
│ │ └─ middleware.go
│ └─ version/
│ └─ version.go
├─ web/
│ ├─ index.html
│ ├─ styles.css
│ └─ app.js
├─ Caddyfile
├─ Dockerfile
├─ Makefile
├─ backup.sh
├─ LICENSE
└─ README.md
```
---
## Go Server (single binary)
```go
// cmd/server/main.go
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"embed"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"database/sql"
_ "modernc.org/sqlite"
)
//go:embed ../../web/*
var webFS embed.FS
//go:embed ../../internal/db/schema.sql
var schemaSQL string
type App struct {
db *sql.DB
secret []byte // for cookie signing
}
func main() {
dsn := env("DB_PATH", "data/app.db")
secret := []byte(env("APP_SECRET", "change-me"))
if err := os.MkdirAll(filepath.Dir(dsn), 0o755); err != nil {
log.Fatal(err)
}
db, err := sql.Open("sqlite", dsn+"?_busy_timeout=5000&_fk=1")
if err != nil { log.Fatal(err) }
if err := migrate(db, schemaSQL); err != nil { log.Fatal(err) }
app := &App{db: db, secret: secret}
mux := http.NewServeMux()
// static files
fs := http.FS(webFS)
mux.Handle("/", withSecurityHeaders(http.FileServer(fs)))
// api routes
mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request){
writeJSON(w, map[string]string{"pong":"ok", "time": time.Now().UTC().Format(time.RFC3339)})
})
mux.HandleFunc("/api/items", app.itemsHandler)
mux.HandleFunc("/export", app.exportCSV)
srv := &http.Server{
Addr: ":8080",
Handler: logRequests(mux),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Println("listening on :8080")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}
func migrate(db *sql.DB, sqlText string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.ExecContext(ctx, sqlText)
return err
}
// minimal example entity
type Item struct {
ID int64 `json:"id"`
Title string `json:"title"`
Note string `json:"note"`
}
func (a *App) itemsHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
rows, err := a.db.Query("SELECT id, title, note FROM items ORDER BY id")
if err != nil { http.Error(w, err.Error(), 500); return }
defer rows.Close()
var out []Item
for rows.Next(){ var it Item; if err:=rows.Scan(&it.ID,&it.Title,&it.Note); err!=nil { http.Error(w, err.Error(), 500); return }; out=append(out,it) }
writeJSON(w, out)
case http.MethodPost:
var in Item
if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, "bad json", 400); return }
if strings.TrimSpace(in.Title) == "" { http.Error(w, "title required", 400); return }
res, err := a.db.Exec("INSERT INTO items(title,note) VALUES(?,?)", in.Title, in.Note)
if err != nil { http.Error(w, err.Error(), 500); return }
id, _ := res.LastInsertId(); in.ID = id
writeJSON(w, in)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (a *App) exportCSV(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", "attachment; filename=items.csv")
cw := csv.NewWriter(w)
defer cw.Flush()
cw.Write([]string{"id","title","note"})
rows, err := a.db.Query("SELECT id, title, note FROM items ORDER BY id")
if err != nil { http.Error(w, err.Error(), 500); return }
defer rows.Close()
for rows.Next(){ var id int64; var title, note string; rows.Scan(&id,&title,¬e); cw.Write([]string{strconv.FormatInt(id,10), title, note}) }
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(v)
}
func withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request){
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'self'")
next.ServeHTTP(w, r)
})
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request){
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
func env(k, def string) string {
if v := os.Getenv(k); v != "" { return v }
return def
}
```
---
## SQLite Schema (idempotent migrations)
```sql
-- internal/db/schema.sql
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
note TEXT DEFAULT ''
);
INSERT OR IGNORE INTO meta(key,value) VALUES ('schema_version','1');
```
---
## Static Frontend (no build tools)
```html
100‑Year SaaS
Items
Add
Export CSV
```
```css
/* web/styles.css */
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
input { padding: .5rem; margin-right: .5rem; }
button { padding: .5rem .75rem; }
ul { list-style: none; padding: 0; }
li { padding: .5rem 0; border-bottom: 1px solid #ddd; }
```
```js
// web/app.js
async function load(){
const res = await fetch('/api/items');
const items = await res.json();
const ul = document.getElementById('list');
ul.innerHTML = '';
for (const it of items){
const li = document.createElement('li');
li.textContent = `${it.id}. ${it.title} — ${it.note||''}`;
ul.appendChild(li);
}
}
const f = document.getElementById('f');
f.addEventListener('submit', async (e)=>{
e.preventDefault();
const title = document.getElementById('title').value.trim();
const note = document.getElementById('note').value.trim();
if(!title) return;
await fetch('/api/items', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({title, note})});
document.getElementById('title').value='';
document.getElementById('note').value='';
load();
});
load();
```
---
## Caddy (auto‑TLS, one file)
```caddyfile
# Caddyfile
:80 {
redir https://{host}{uri}
}
:443 {
encode zstd gzip
reverse_proxy 127.0.0.1:8080
}
```
> Put the Caddyfile next to the binary and run `caddy run --config Caddyfile`. For a domain, replace `:443` with your domain block and let Caddy issue certs automatically.
---
## Dockerfile (distroless‑style)
```dockerfile
# Dockerfile
FROM golang:1.22 as build
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o /bin/app ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /bin/app /app
COPY web /web
COPY internal/db/schema.sql /internal/db/schema.sql
ENV DB_PATH=/data/app.db
ENV APP_SECRET=change-me
EXPOSE 8080
ENTRYPOINT ["/app"]
```
* Run with a volume for the DB: `docker run -p 8080:8080 -v $(pwd)/data:/data app:latest`
* Put Caddy in a sidecar or run it on the host.
---
## Makefile (1‑liner conveniences)
```makefile
run:
DB_PATH=data/app.db APP_SECRET=local go run ./cmd/server
build:
CGO_ENABLED=0 go build -o bin/app ./cmd/server
fmt:
gofmt -s -w .
tidy:
go mod tidy
```
---
## Backups (cron‑friendly)
```bash
#!/usr/bin/env bash
# backup.sh
set -euo pipefail
SRC=${DB_PATH:-data/app.db}
DST_DIR=${BACKUP_DIR:-backups}
mkdir -p "$DST_DIR"
ts=$(date -u +%Y%m%d-%H%M%S)
cp "$SRC" "$DST_DIR/app-$ts.db"
# optional: compress
# gzip -9 "$DST_DIR/app-$ts.db"
```
**Cron example** (daily at 02:15 UTC):
```
15 2 * * * DB_PATH=/srv/app/data/app.db BACKUP_DIR=/srv/backups /srv/app/backup.sh
```
---
## Operating Without Surprises
* **Zero ORMs (optional)**: native SQL keeps queries explicit and portable. Drizzle/Prisma not needed.
* **No task runners**: cron + shell covers 99% of automation.
* **No queues**: if you must, use SQLite table + polling (simpler than Redis).
* **Auth**: keep it minimal—HTTP Basic for internal tools, or signed cookies with HMAC.
* **Observability**: append logs; rotate with `logrotate` monthly.
---
## Data Portability & Survival Plan
* `/export` returns CSV; add `/export/json` for JSON dumps.
* **Cold storage**: rsync your `data/` and `backups/` to a cheap object store quarterly.
* **Reproducibility**: a fresh VM can boot this app with just Go (or Docker) + Caddy.
* **Docs**: README includes a single “bring‑up” section that anyone can follow.
---
## Hardening Checklist (short)
* Run as non‑root (systemd/User=app)
* `fs.protected_hardlinks=1` etc. via sysctl (optional)
* Restrict CORS to your domain
* Regular SQLite `VACUUM` (monthly) to keep file tidy
* Keep `APP_SECRET` off repo; rotate annually
---
## systemd Unit (optional, if not using Docker)
```ini
# /etc/systemd/system/century.service
[Unit]
Description=100-Year SaaS
After=network.target
[Service]
User=app
WorkingDirectory=/srv/app
Environment=DB_PATH=/srv/app/data/app.db
Environment=APP_SECRET=use-a-random-string
ExecStart=/srv/app/bin/app
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
---
## What To Add Later (only if truly needed)
* Email: SMTP via stdlib; avoid heavy SDKs
* File uploads: store on disk; only add S3/R2 if storage grows too big
* Search: SQLite FTS5 extension
* Background jobs: a single `jobs` table + ticker
---
## Why This Endures
* **Go binary** runs anywhere for decades
* **SQLite** is likely to be readable forever
* **HTTP/HTML** is universal
* Zero exotic services; everything is files you can copy
You can now copy this repo layout, paste in your app logic, and have something that will plausibly run for decades with backups and almost no upkeep.