Skip to content
Back to all projects
Live 2024 — present · 11 min read

Machs Dir Selbst Solar

DIY solar learning platform — videos, tutorials and guides plus three planning apps for end customers who plan and install their PV system themselves

DIY solar platform for end customers who want to plan and (mostly) install their PV system themselves — in the spirit of a learning platform like Udemy, focused on photovoltaics. Learning content (videos, tutorials, component guides, step-by-step instructions) plus three planning apps together form the DIY toolkit: a customer-facing Builder for inquiries with a maps-based roof editor, a Configurator for the detailed offer including material choice and acceptance, and an internal Planner dashboard with heavy automation for the few remaining support touchpoints. The apps as an Nx monorepo on a shared Supabase backend — with a preview pipeline that gives every pull request its own database, three containers and three subdomains, fully automatically.

01 Problem

Solario wanted to launch a second product line for a very different audience: DIY-minded customers who happily dive in deep, watch videos, compare components and end up planning their system themselves and (with a local electrician for the grid hookup) installing it themselves. For this to be commercially viable, staff effort per inquiry had to drop drastically — customers had to get most of the way through on learning content and self-service tools alone. Three different user groups with very different requirements, working off a shared data model: end customers modelling their own PV system on a map and wanting a rough price and material estimate; the same end customers later, during detailed configuration, choosing materials and accepting the offer; and internal planners whose work should be automated as far as possible.

02 Solution

Nx monorepo with three independently deployable Angular 21 apps and a single Supabase backend, embedded in a content platform with learning videos and step-by-step guides that customers consume before and during planning. PR-based preview infrastructure: every pull request automatically gets an isolated Supabase branch plus three Coolify deploys wired up to the preview database. Reviewers see changes per app under dedicated subdomains, without parallel PRs contaminating each other.

03 Outcome

Three apps with a shared codebase and full data isolation per PR. The DIY line runs at a fraction of the per-inquiry staff effort of Solario's classic sales — primarily because end customers complete the inquiry and configuration steps themselves, the learning content answers most questions upfront, and only qualified, internally consistent requests reach a Solario planner. Reviewers can test customer-facing flows in the current PR version without setting anything up locally.

Three apps, one data layer — embedded in a learning platform

The platform consists of three Angular 21 apps, each serving a clearly bounded user group and sharing substantial domain logic. They are the tooling layer inside a larger content platform — DIY customers typically consume the learning content first (component basics, sizing rules, regulatory framework, step-by-step self-install videos) before they even start modelling their system in the Builder. That shifts the customer expectation: people who land here don’t want to be advised, they want tools.

Builder — the customer-facing inquiry app. The customer enters an address, models their roof segments in a maps editor (polygon-drawing over satellite imagery), defines module areas, and roughly picks what to inquire about. Out comes a rough price range and a bill of materials and tools for a DIY approach. Anyone who’s already learned from the videos and guides what an MC4 connector is and how a string layout works gets through on their own. Solario gets an internally consistent, realistic inquiry; the customer gets an honest expectation.

Configurator — the detailed offer app, which the customer enters once Solario has worked on the inquiry. The finished offer lives here: concrete module series, concrete inverter, optional components with surcharge, detailed economic statistics. The customer can swap materials (premium vs base module), sees the impact on price, yield and payback instantly, and can accept the offer directly — no appointment, no phone call.

Planner — the internal tool, built around automation. From a Builder inquiry, the Planner drafts an offer, fills in missing technical details from master data (which wiring fits, which inverter matches which string layout, which subsidies apply regionally), and the planner only checks and ships it. A single planner can handle the volume that would tie up several consultants in the classic sales line. Master-data editing (manufacturers, modules, inverters, electrical firms, subsidies, price assumptions) happens here too.

All three share libs/economics (cashflow, annuity, price escalation, payback) and libs/utils. nx affected builds and tests only what a change actually touches — across four targets per app, it’s the difference between a pleasant CI and an unusable one.

libs/economics/src/lib/economics.ts ts
export class EconomicsUtils {
  static DEFAULT_YEARS_FOR_CALCULATION = 25;
  static ELECTRICITY_PRICE_YEARLY_INCREASE_PERCENTAGE = 0.04;

  static calculate_payback_period(profit_list_euro: number[], total_cost_euro: number): number {
    let years = 0;
    let cumulative = 0;
    while (total_cost_euro > cumulative && years < profit_list_euro.length) {
      cumulative += profit_list_euro[years];
      years += 1;
    }
    const remaining = cumulative - total_cost_euro;
    if (remaining > 0 && years > 0) {
      return years - (remaining / profit_list_euro[years - 1]);
    }
    return years;
  }
}

What the preview infrastructure actually does

The standout piece of infrastructure here isn’t a single app, it’s preview.yml. It turns every PR into a complete, isolated test environment: its own Postgres DB, its own three containers, its own three URLs.

For every PR the workflow runs through these steps:

  1. Ensure a Supabase branch. Look up the preview branch matching the PR’s git branch via the Management API, or create one. Poll up to 15 minutes for ACTIVE_HEALTHY, then read branch-specific credentials (DB password, anon key, JWT secret).
  2. Sync edge function secrets. Preview branches inherit functions from the parent project but not their secrets — without this step, every Deno.env.get(...) returns undefined on previews.
  3. Bootstrap dashboard-only schemas. The supabase_functions schema and http_request trigger exist in production because they were created via a dashboard click. They don’t exist on a branch — the workflow installs a no-op stub with the same signature so migration triggers don’t fail.
  4. Set the vault JWT secret. Custom JWT-signing functions read app.jwt_secret from the Postgres vault. A fresh branch lacks that row — the workflow inserts it with the branch’s own JWT secret so PostgREST/GoTrue can verify the self-issued tokens.
  5. Push migrations and edge functions. supabase db push --include-all and supabase functions deploy sync the branch with the PR HEAD. On opened/reopened, seed.sql runs too; on synchronize it doesn’t (the seed isn’t idempotent).
  6. Build and deploy affected apps. nx show projects --affected --type=app produces the list. For each, build a Docker image with PR-specific build args, push to GHCR, trigger Coolify’s /api/v1/deploy with the new tag.
  7. Update the PR comment. A sticky comment lists the three preview URLs; on opened, a Nextcloud Talk message also goes out so the team knows the preview is live.
  8. Teardown on closed. Delete the Supabase branch and tear down three Coolify previews via DELETE /api/v1/applications/<uuid>/previews/<pr>.

Highlights

Build args instead of runtime substitution

Values that change per PR (Supabase URL, anon key, app URLs) go into the image as Docker build args. Each PR gets a fully immutable artifact — no drift between “the container I’m testing” and “the configuration that’s actually active”. Values that can be shared across previews stay as runtime env vars.

.github/workflows/preview.yml yaml
- name: Build, push to GHCR, trigger Coolify deploy
  env:
    SUPABASE_URL: ${{ steps.supabase.outputs.url }}
    SUPABASE_ANON_KEY: ${{ steps.supabase.outputs.anon }}
    PLANNER_URL: ${{ steps.urls.outputs.planner }}
  run: |
    image="ghcr.io/$repo/${name}:pr-${PR_NUMBER}"
    docker buildx build \
      --build-arg "SUPABASE_URL=${SUPABASE_URL}" \
      --build-arg "SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}" \
      --build-arg "PLANNER_URL=${PLANNER_URL}" \
      --tag "$image" --push .

    curl -sS -X POST -H "Authorization: Bearer $COOLIFY_TOKEN" \
      "$COOLIFY_URL/api/v1/deploy?uuid=$uuid&pr=$PR_NUMBER&docker_tag=pr-${PR_NUMBER}"

Polling branch creation with a real failure model

Branch provisioning is async. The workflow distinguishes ACTIVE_HEALTHY (ready) from MIGRATIONS_FAILED (bail out immediately). 15-minute deadline, because a full cold start with migrations can take 10 minutes. Plus two defensive pre-checks: without an existing production branch, Supabase would auto-promote the first POST to default — we insist that a production branch exists and isn’t accidentally the PR’s.

.github/workflows/preview.yml bash
deadline=$(( $(date +%s) + 900 ))  # 15m
while : ; do
  branch=$(curl -sS -H "Authorization: Bearer $TOKEN" \
            "https://api.supabase.com/v1/branches/$branch_id")
  status=$(echo "$branch" | jq -r '.status // empty')
  case "$status" in
    ACTIVE_HEALTHY|FUNCTIONS_DEPLOYED|MIGRATIONS_PASSED)
      break ;;
    MIGRATIONS_FAILED|FUNCTIONS_FAILED)
      echo "::error::Preview branch failure: $status"
      exit 1 ;;
  esac
  [[ $(date +%s) -ge $deadline ]] && { echo "::error::timeout"; exit 1; }
  sleep 10
done

Bootstrap for dashboard-only schemas

Supabase creates the supabase_functions schema and the http_request trigger function when you click “Database Webhooks” in the dashboard. Branches don’t inherit that. A fresh preview branch with CREATE TRIGGER ... supabase_functions.http_request in its migrations dies on first apply. Fix: a no-op stub with the same signature that just returns NEW — triggers are happy, and previews shouldn’t fire real webhooks against production anyway.

.github/workflows/preview.yml sql
CREATE SCHEMA IF NOT EXISTS supabase_functions;
DO $bootstrap$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM pg_proc p
      JOIN pg_namespace n ON p.pronamespace = n.oid
    WHERE n.nspname = 'supabase_functions' AND p.proname = 'http_request'
  ) THEN
    EXECUTE $body$
      CREATE FUNCTION supabase_functions.http_request() RETURNS trigger
      LANGUAGE plpgsql AS $fn$ BEGIN RETURN NEW; END $fn$
    $body$;
  END IF;
END $bootstrap$;

Affected-aware builds on synchronize

When a PR is first opened, all three apps are built — the reviewer should get a complete URL set. On every later commit (synchronize event), the workflow runs nx show projects --affected --type=app and rebuilds only the apps whose dependency graph was actually touched by the commit. Three builds usually fall to one or two.

.github/workflows/preview.yml yaml
- name: Compute affected apps
  id: affected
  run: |
    if [[ "${{ github.event.action }}" != "synchronize" ]]; then
      # opened/reopened: build all three apps
      { echo "builder=true"; echo "planner=true"; echo "configurator=true"; } \
        >> "$GITHUB_OUTPUT"
      exit 0
    fi
    affected=$(pnpm exec nx show projects --affected --type=app)
    for app in builder planner configurator; do
      if echo "$affected" | grep -qx "$app"; then
        echo "${app}=true" >> "$GITHUB_OUTPUT"
      else
        echo "${app}=false" >> "$GITHUB_OUTPUT"
      fi
    done

Clean teardown on PR close

Coolify’s built-in cleanup path only covers apps built directly from a Git source — Docker-image apps have to be torn down explicitly. The teardown job calls DELETE /api/v1/applications/<uuid>/previews/<pr> and accepts 404 as “already gone”. It also deletes the Supabase branch via DELETE /v1/branches/<id> — with a double safety clause (is_default != true) so a misconfiguration can never touch the production branch.

.github/workflows/preview.yml bash
# Suspenders: is_default != true guarantees we can never select the prod branch.
branch=$(echo "$branches" | jq -c \
           --arg g "$PR_BRANCH" \
           '[.[] | select(.git_branch == $g and .is_default != true)] | .[0] // empty')
if [[ -z "$branch" ]]; then
  echo "No Supabase preview branch for $PR_BRANCH — nothing to delete"
  exit 0
fi
branch_id=$(echo "$branch" | jq -r '.id')
curl -sS --fail -X DELETE \
  -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
  "https://api.supabase.com/v1/branches/$branch_id" > /dev/null

What a year of operation has shown

Three observations we wouldn’t have seen as clearly on day one.

The preview pipeline is an onboarding tool. Onboarding new developers takes basically zero minutes — they open a PR, get three URLs, click in. The real strength isn’t code review, it’s letting external stakeholders (Solario sales, designers) work on a real version without a local setup.

Edge function secrets are the quiet stumbling point. About half the preview problems we’ve seen had the same trigger: a new secret in apps/supabase/functions/.env that didn’t get added to the sync list. We enforce it via CODEOWNERS now.

Build args eliminate a class of “works locally, not in preview” bugs. The first version used build-time substitution from env vars into environment.preview.ts (a sed-based script). It works but is fragile — every new env has to stay in sync across two lists. Build args put it all in one place.

05 Key decisions

Architecture choices that paid off

01

One Nx monorepo, three deployable apps

Instead of three separate repos with copy-pasted code: one repo, three apps under `apps/{planner,configurator,builder}`, shared domain in `libs/`. `nx affected` makes sure builds and tests only run where they have to — otherwise every PR would trigger the full pipeline.

02

Supabase branching for isolated preview DBs

Every PR gets its own Postgres database, built automatically from the migrations. Reviewers can insert data without leaking it into other PRs — and without "test data" ever sneaking into production. On PR close, the branch is deleted automatically.

03

Per-PR Coolify deploys, driven via API

Three pre-provisioned Coolify applications with a preview flag. For each affected app, the GitHub workflow builds a Docker image with PR-specific build args, pushes it to GHCR, and triggers a Coolify deploy with the freshly built image tag. On PR close, a teardown job explicitly calls `DELETE /api/v1/applications/<uuid>/previews/<pr>` — Docker-image apps are not auto-cleaned by Coolify's GitHub App webhook.

04

Build args, not runtime substitution

Per-PR values (Supabase URL, anon key, app URLs) go into the image as Docker build args — one build per PR, one push, Coolify pulls the tag. Values shared across all previews (Google Maps key, Meta Pixel) stay as runtime env vars in the container. No race between concurrent PRs on a shared env var.

05

Three apps, one auth model

End customers (in the Builder and Configurator) and internal planners have different roles but share the same auth system (Supabase Auth). The apps differ only in routes and components — permission checks sit in the database (RLS). A forgotten permission in the frontend cannot leak data.

06

Affected-aware preview deploys

On a `synchronize` event (new commit on a PR), the workflow only rebuilds the apps whose transitive dependencies actually changed. On `opened` or `reopened`, all three apps are built so the reviewer starts with a full URL set. In practice this skips two out of three builds most of the time.

07 Lessons learned

  • 01 Preview infrastructure pays off the moment three people work on the code and reviewers want to see real data. Before that, it is an expensive toy.
  • 02 Build args are cleaner than runtime substitution when the values only change once per deployment anyway. The per-PR-tagged image is automatically the source of truth — no drift between container and env.
  • 03 `nx affected` is the difference between a 2-minute and a 20-minute CI on four apps. Without it, the monorepo approach would not be viable.
  • 04 Supabase branching makes the hard problem (isolated DB per PR) trivial. The real stumbling blocks are the things branches don't auto-inherit: edge function secrets, vault entries, dashboard-only schemas.
  • 05 Coolify's API isn't pretty, but it is documented well enough for CI automation. If you use Docker-image apps, you have to call cleanup yourself — the GitHub App webhook only handles Git-source apps.
Contact

Sound familiar?

Send me a short note about what you are dealing with. I will respond within 24 hours with an honest assessment — even if I am not the right partner.