Bkjr Projects

How I run every dev project

My roadmap and checklist for building offline-first, hosting on Firebase, shipping through pull requests and CI, keeping secrets out of reach, and releasing to a preview before anything goes live.

Working notes. Placeholders like <project-id> stand in for real values.

01Offline-first and local

Everything should run on my Mac without the internet: the code, the AI tools, the dev server and the backups. The cloud is where I publish, not where I depend.

Local AI models on the Mac

Run models locally with Ollama so drafts, summaries and code help stay on the machine with no per-token bill. Apple Silicon runs small and mid-size models well; pick a model that fits the Mac's memory.

brew install ollama            # or install the Mac app from ollama.com
ollama pull llama3.2           # download a model once, then it works offline
ollama run llama3.2 "Summarize this README"
ollama list                    # what's installed
# local API, for scripts and apps:
curl http://localhost:11434/api/generate \
  -d '{"model":"llama3.2","prompt":"Hello"}'
  • The Ollama API listens on localhost by default. Keep it that way; reach it from other devices only over a private network (Tailscale), never an open port.
  • Want a chat window? Run Open WebUI in Docker, bound to 127.0.0.1 only. LM Studio is a desktop alternative.
  • Keep a list of which model each project uses, so results are repeatable.

Offline-capable sites and PWAs

A service worker caches the app shell so pages open with no signal. Add a web app manifest and the site installs like an app.

// main script
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');

// sw.js: cache the shell, network-first for pages
const CACHE = 'app-v1';   // bump on every release
const SHELL = ['/', '/offline.html', '/assets/css/app.css', '/assets/js/app.js'];
self.addEventListener('install', (e) =>
  e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL))));
self.addEventListener('activate', (e) =>
  e.waitUntil(caches.keys().then((keys) => Promise.all(
    keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))));
self.addEventListener('fetch', (e) => {
  if (e.request.mode === 'navigate') {
    e.respondWith(fetch(e.request).catch(() =>
      caches.match(e.request).then((r) => r || caches.match('/offline.html'))));
  } else {
    e.respondWith(caches.match(e.request).then((r) => r || fetch(e.request)));
  }
});
  • Service workers only run on HTTPS or localhost.
  • Serve sw.js with Cache-Control: no-cache so updates arrive.
  • Test it: DevTools > Application > Service workers, then tick "Offline" and reload.

Local dev servers

Preview everything on the Mac before it touches the cloud. Bind to 127.0.0.1 so nothing on the network can reach it.

# static folder, quickest
python3 -m http.server 8080 --bind 127.0.0.1

# Firebase Hosting emulator: same rewrites, redirects and headers as production
firebase emulators:start --only hosting     # serves on http://localhost:5000

# dependencies from the local cache when the network is slow or gone
npm ci --prefer-offline
  • Use the emulator when testing firebase.json changes: redirects and headers behave as they will live.
  • Save the docs I rely on (PDF or offline docs) so I can keep working without a connection.

Backups

The 3-2-1 rule: three copies, on two kinds of media, one offsite. A backup only counts once I've restored from it.

  • Mac: Time Machine to an external drive, and FileVault on.
  • Code: push every branch to GitHub; nothing important lives only on one disk.
  • Encrypted offsite copy: restic to object storage or a second drive; the repository password lives in my password manager.
  • Databases: a scheduled dump (for example pg_dump -Fc), plus a restore into a scratch database each month.
restic -r <repository> init
restic -r <repository> backup <projects-folder>
restic -r <repository> snapshots
restic -r <repository> forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune

02Hosting

Static sites go on Firebase Hosting. Every change lands on a preview channel first; live only ever gets an exact copy of a preview I've already checked.

Preview channels and clone-to-live

# deploy to a temporary preview URL (hosting only)
firebase hosting:channel:deploy <channel> --only <target> \
  --project <project-id> --expires 7d --account <your-account>

# after checks pass: copy that exact release to live
firebase hosting:clone <site-id>:<channel> <site-id>:live
  • Preview channels expire on their own; --expires keeps them from piling up.
  • Cloning promotes the files I tested, byte for byte. No rebuild between preview and live.
  • Pass --account on every command when more than one Google account is signed in.

Rollback

Before every release, write down the live version ID. That's the rollback target.

# what's live right now (note the version ID)
firebase hosting:channel:list --site <site-id> --project <project-id>

# put a known-good version back on live
firebase hosting:clone <site-id>@<version-id> <site-id>:live
  • The Firebase console's Hosting release history has a Rollback button too.
  • Roll back first, debug second. Fix forward on a branch, then release again.

Custom domains with GoDaddy DNS

  1. Firebase console > Hosting > Add custom domain. Add the apex and www.
  2. Firebase shows the records to add: a TXT record to prove ownership, then the A records. Copy them exactly from the console.
  3. GoDaddy > Domain > DNS: add the TXT record, replace the parked @ A records with Firebase's, and remove any GoDaddy forwarding.
  4. Leave the email records (MX, SPF, DKIM, DMARC) alone.
  5. Wait for DNS to spread; Firebase issues the SSL certificate on its own once the records resolve.
dig +short <your-domain>.com A
dig +short TXT <your-domain>.com

When to use Cloud Functions

Use Functions when the work needs a secret or a trusted server: payment and other webhooks, sending email, scheduled jobs, and writes that must be validated server-side.

Skip Functions for anything a static page, the browser or Firestore security rules can handle.

  • Functions need the pay-as-you-go Blaze plan.
  • Deploy one function at a time (firebase deploy --only functions:<name>), only with an explicit yes.
  • Website releases use --only hosting, so a site release never ships server code by accident.

Cost guardrails

  • Set a Cloud Billing budget with alerts on every paid project. Alerts warn; they don't stop spending.
  • Cap Functions: set maxInstances, short timeouts and small memory; keep minInstances at zero unless a cold start really hurts.
  • Keep projects that only host static files on the Spark plan, which has no billing account attached.
  • Limit how many old Hosting releases are kept, and let preview channels expire.
  • Cache static assets with long Cache-Control headers and fingerprinted file names to cut bandwidth.
  • Check the Usage and billing page monthly.

03Source control and CI

GitHub is the record of every change. Nothing reaches main without a pull request and a passing build.

Branches and pull requests

  • main is always releasable.
  • One branch per change: feature/<thing> or fix/<thing>.
  • Open a PR for every change, with what changed, why, and how it was tested.
  • Merge with a normal merge commit once CI is green.
  • Generated files are rebuilt by their script, never hand-edited.
git switch -c feature/<thing>
git add -A && git commit -m "Describe the change"
git push -u origin feature/<thing>
gh pr create --fill
gh pr checks --watch
gh pr merge --merge

Never force-push

Force-pushing rewrites history that others (and CI) already have. If something needs undoing, add a commit that undoes it.

git revert <commit>     # undo a change with a new commit
git push

Protect main with a ruleset: require a pull request, require status checks, block force pushes and deletions.

CI tests and secret scanning

  • Every PR runs the test suite (node --test) and the site build.
  • Every push runs a secret scan (gitleaks), and GitHub push protection blocks known secret formats before they land.
  • Tests guard the rules that matter: copy wording, noindex pages, redirects, security headers.
  • A red check means stop. Fix the cause; never skip or weaken a test to get green.

The workflow-scope note

Pushing a change to anything under .github/workflows/ needs a GitHub token with the workflow scope. A GitHub CLI login without it can push code but gets rejected on workflow files.

gh auth refresh -s workflow   # add the scope once, in the browser
gh auth status                # confirm the scopes

04Security

Boring and least-privilege. Re-run this list every quarter and whenever a new service is added.

Secrets management

  • Secrets never go in git: .env is in .gitignore, and .env.example lists names only.
  • Server secrets live in Secret Manager (firebase functions:secrets:set <NAME>); CI secrets live in GitHub Actions secrets.
  • Personal credentials live in a password manager.
  • Never paste a secret into a chat, a log, an issue or a screenshot. If one leaks, rotate it right away.

API key restrictions and App Check

  • Firebase web keys identify the project; they aren't secret, but they should be restricted.
  • In Google Cloud > Credentials, limit each browser key to my domains (HTTP referrers) and to the APIs it needs. Server keys get their own, separate restrictions.
  • Turn on App Check (reCAPTCHA Enterprise for web), watch its metrics, then enforce it for Firestore, Functions and Storage.

Content Security Policy and headers

  • Enforce a CSP that lists only the sources each site uses. Roll a new policy out in report mode first, read the reports, then enforce.
  • Keep scripts in files rather than inline, so the policy can stay strict.
  • Send X-Content-Type-Options: nosniff, a Referrer-Policy, a Permissions-Policy and frame-ancestors.

Least-privilege service accounts

  • Automation gets its own service account with only the role it needs (for deploys, Firebase Hosting Admin), never Owner or Editor.
  • For GitHub Actions, prefer Workload Identity Federation over downloaded JSON keys.
  • Keep separate projects for dev and production.
  • Review IAM members and keys each quarter; remove what isn't used.
  • Two-factor sign-in on GitHub, Google, the domain registrar and Microsoft 365.

Email authentication: SPF, DKIM, DMARC

  1. SPF (TXT at the root): v=spf1 include:spf.protection.outlook.com -all, plus any other service that sends as the domain.
  2. DKIM: in the Microsoft Defender portal's email authentication settings, select the domain, publish the two selector1._domainkey and selector2._domainkey CNAME records it shows, then enable signing.
  3. DMARC (TXT at _dmarc): start with v=DMARC1; p=none; rua=mailto:<reports-address>, read the reports, then move to p=quarantine and p=reject.

Data privacy

  • Collect the least data that does the job, and say what's collected in the privacy policy.
  • Keep personal data out of URLs, logs and analytics events.
  • Firestore and Storage rules deny by default; open only what each screen needs.
  • Set how long data is kept and how a person can ask for it to be deleted.
  • Use placeholders, never real hostnames, addresses, account names or IDs, in public docs and screenshots.

05Release process

Preview, then smoke checks, then live. The same steps every time, written down as they happen.

  1. Merge. The PR is green in CI and merged with a normal merge commit.
  2. Build from a clean copy of main. Run the build scripts and confirm a rebuild changes nothing.
  3. Record the rollback target. Write down the live version ID before touching anything.
  4. Deploy a preview channel. Hosting only, with an expiry.
  5. Run the smoke checks on the preview URL. Key pages load, the error page returns 404, redirects return 301, security headers are present, sitemaps and robots are right, no JavaScript errors, forms work, layout shift stays low, and the copy rules hold.
  6. Get the yes. Live needs an explicit approval for this release.
  7. Clone the preview to live, then run the same checks on the real domain.
  8. Log it. Version, time, commit, check results and rollback command go in the ledger.
Stop-on-failure rule: if any check fails, stop at preview. Don't promote, don't patch live by hand. Write down what failed, fix it on a branch, and start the release again from step one.

06Monetization and analytics

AdSense readiness

  • The site has real, original content, a privacy policy that explains ad cookies, and a contact page.
  • Ads stay off utility, checkout, account, error and noindex pages.
  • Visitors in the EEA, UK and Switzerland see a consent message from a Google-certified consent platform (AdSense Privacy & messaging covers this).
  • Ads stay switched off in the code until the publisher ID is in place; then a preview release checks placements and layout shift before live.

ads.txt

One line at the site root, generated from the publisher ID rather than typed by hand:

google.com, pub-<your-publisher-id>, DIRECT, f08c47fec0942fa0

Check it at https://<your-domain>.com/ads.txt after release; AdSense can take a few days to read it.

Analytics

  • Measure only what a decision needs: which pages get used, which searches find nothing, where people drop off.
  • Search Console for indexing and search terms; analytics only with consent where the law asks for it.
  • No personal data in events.

The ledger

One running record of everything I ship:

  • Products: name, status, URL and revenue, one row each.
  • Releases: date and time, live version, commit, what changed, check results, rollback command.
  • Follow-ups: what's open and who it's waiting on.

Update it at the end of every release, while the details are fresh.

07What I need to do

The open items only I can do. Checked boxes are remembered on this device.

08Starter template for a new project

Copy this shape for every new project so the hosting, tests and release steps work on day one.

Repo layout
my-project/
├── public/                  # what gets hosted (or the build output folder)
│   ├── index.html
│   ├── 404.html
│   ├── offline.html
│   ├── manifest.webmanifest
│   └── sw.js
├── src/                     # source, if there's a build step
├── functions/               # only if the project needs server code
├── scripts/                 # build and smoke-check scripts
├── tests/                   # node --test files
├── .github/workflows/ci.yml
├── firebase.json
├── .firebaserc              # project alias: <project-id>
├── .gitignore               # .env, node_modules, build output, any key files
├── .env.example             # setting names, never values
├── package.json
└── README.md                # run, test, preview, release, roll back
firebase.json
{
  "hosting": {
    "public": "public",
    "cleanUrls": true,
    "trailingSlash": false,
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
    "headers": [
      { "source": "**", "headers": [
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
        { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(self)" },
        { "key": "Content-Security-Policy", "value": "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" }
      ] },
      { "source": "/sw.js", "headers": [ { "key": "Cache-Control", "value": "no-cache" } ] },
      { "source": "/assets/**", "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] }
    ]
  }
}

Only use the long immutable cache on fingerprinted asset names (for example app.3f9c.js). Widen the CSP one source at a time as the project needs it.

CI workflow (.github/workflows/ci.yml)
name: CI
on: [pull_request, push]
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npm test
      - run: npm run build
  secrets:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Pushing this file needs the workflow scope (see section 03). Take action versions from each action's README.

Preview-first release, start to finish
npm ci && npm test && npm run build

# rollback target first
firebase hosting:channel:list --site <site-id> --project <project-id> --account <your-account>

# preview (hosting only, expires on its own)
firebase hosting:channel:deploy release-<date> --only hosting \
  --project <project-id> --expires 7d --account <your-account>

# smoke checks against the preview URL the command printed
node scripts/smoke.mjs https://<preview-url>

# stop here if anything failed. With an explicit yes:
firebase hosting:clone <site-id>:release-<date> <site-id>:live --account <your-account>
node scripts/smoke.mjs https://<your-domain>.com

# if live misbehaves
firebase hosting:clone <site-id>@<previous-version-id> <site-id>:live --account <your-account>

09Project backlog

Each one makes me less dependent on someone else's meter.

Next

Local AI server

Ollama on the Mac with one model per job, Open WebUI for chat, reachable from my phone only over a private network.

Next

Offline-ready Ballers Map

A service worker that keeps the last search and the map shell working with no signal at a field.

Idea

Automations I own

Self-hosted n8n for the glue between tools, after reading its license terms.

Idea

Directory API

Postgres with PostGIS behind a small API, so map listings and edits live in a database I control.

Idea

Private Git mirror

Forgejo on a private network, mirroring every repo I own.

Idea

Status page

Uptime Kuma on a different machine from the sites it watches, with a simple status page.