Changelog

Release archive

Older Pushify releases. The latest updates live on the main changelog.

v0.2.0-beta.73

Platform & API

Fixed

  • A static site with a custom domain answered 502. Verifying a domain (and deleting one, or editing its Nginx settings) always wrote the app template — a proxy to a container on 127.0.0.1:<port> — whatever the project was. A static site (uploaded or Site Studio) has no container, so the request failed and fell through to the wake endpoint on the control plane, which answered 502 as well; the certificate itself was fine. syncProjectSites now recognises a static project and writes a vhost that serves its folder (same domains, www redirects, certificates and HSTS as an app; hidden files never served), and publishing a static site with a domain goes through the same path instead of its own single-domain file. Vhosts an earlier version left for the site's domains are removed, but only when they name one of those domains (on a shared runner the bare slug may belong to another organisation). A site broken this way is fixed by publishing it again or by re-verifying its domain.

v0.2.0-beta.72

Platform & API

Added

  • Publish a website by uploading its files. POST /api/v1/static-sites creates a project from a dropped folder (many files, each named by its path in the site) or a .zip (file); POST /api/v1/static-sites/:projectId/versions publishes new files to it. Uploads are checked and tidied in lib/static-upload.ts: relative paths only (no .., shell-safe names), hidden files and OS junk (.git, .env, __MACOSX) dropped, a single wrapping folder unwrapped, index.html required at the root, at most 2,000 files / 25 MB per file / 50 MB per site, and zips refused if they would expand past that. Each upload is stored as a version (static_uploads, migration 0060; the last five are kept) and published through the normal deployment pipeline, so it shows up under Deployments with logs, and redeploy and rollback republish the right version.
  • **Static sites get a *.pushify.dev address on Pushify's shared host.** Without a custom domain, a static site (uploaded or Site Studio) on a host carrying the wildcard certificate is served at <slug>.pushify.dev over HTTPS instead of http://ip:port; its DNS record is created as for app deploys.
  • SSHClient.uploadFiles: many files over one SFTP session.

Changed

  • Static publishes swap the whole folder at once: new files are written beside the live ones and moved into place, so visitors never see a half-uploaded site.
  • Static vhosts never serve hidden files (location ~ /\. → 404), whatever is in the folder.

Fixed

  • Deleting a static site left it online. Its files and Nginx vhost stayed on the server — and on the shared host, where *.pushify.dev resolves by wildcard, the site kept answering. Deleting a static project now removes both, and runs no container teardown (that one matches by slug, which on a shared runner is only unique within an organisation).

v0.2.0-beta.71

Platform & API

Fixed

  • A managed server could be started again on the Free plan. When a subscription ends the organisation is moved to Free and its managed servers are stopped, but "Start" only checked the billing status, not the plan. Starting a managed server now requires a plan with managed servers (same rule as creating one); the customer is told to re-subscribe or connect their own server.
  • A late `invoice.payment_failed` no longer turns a suspended organisation into a past-due one. Stripe's last retry can arrive around the cancellation; it used to overwrite suspended with past_due, which the past-due reconcile then cleared to active — leaving an organisation without a subscription unblocked.

v0.2.0-beta.70

Platform & API

Fixed

  • Customers could be charged for a subscription that had already ended. When Stripe exhausts its retries it cancels the subscription but leaves the last invoice open. That invoice showed a "Pay" button, "Pay now" retried it, and the past-due check counted it as debt — paying it would have cost the customer the plan price and given them nothing (the organisation stays on Free). Invoices are now payable only when they are one-off or belong to the organisation's current subscription (lib/stripe-invoices.ts): /billing/invoices returns payable, pay-outstanding skips the rest, and the past-due reconcile ignores invoices of cancelled subscriptions.

v0.2.0-beta.69

Platform & API

Fixed

  • Paid organisations no longer stay "past due". The past_due self-heal from beta.67 only asked Stripe about the linked subscription, so it could not help when no subscription was linked, when the linked one was an old unpaid duplicate while a newer one was active, or when a webhook never arrived. It now looks at the whole Stripe customer: a subscription in good standing (the linked one first, else the newest) clears the flag and becomes the linked subscription (plan taken from its price); nothing live and nothing owed also clears it; a past-due or unpaid subscription, or an open invoice, keeps it. An organisation that really owes is asked about at most once a minute.

Added

  • Billing reconcile worker: every 10 minutes every past_due organisation is re-checked against Stripe, so a customer who pays on Stripe's invoice page, through a Smart Retry or after a card update is unblocked within minutes even if a webhook is lost — without having to click anything first.

v0.2.0-beta.68

Platform & API

Added

  • `npm run billing:diagnose -- --server <id>` (or `--org <id>`): read-only answer to "why is this organisation blocked by billing?" — the organisation's billing row next to every Stripe subscription of its customer and every open invoice (with its pay link and whether it belongs to the current subscription), and a one-line verdict.

v0.2.0-beta.67

Platform & API

Fixed

  • An organisation that had paid stayed "past due" and could not start a stopped server. Invoice webhooks were matched by customer only, so a failed invoice of an older subscription (a duplicate from before in-place plan changes, or one replaced by a new checkout) marked the organisation past due again after it had paid for its current subscription — and a paid invoice of an old subscription could clear the flag for an unpaid current one. invoice.payment_failed, invoice.payment_action_required and invoice.paid now only change billing status for the organisation's current subscription, and "Pay now" only retries that subscription's invoices.
  • A stale past_due flag heals itself. Before blocking an action (and when the billing page loads), a past_due organisation is re-checked against Stripe: if its current subscription is active or trialing, the flag is cleared on the spot. Organisations stuck today are unblocked on their next action, with no script to run.

v0.2.0-beta.71

Dashboard

Fixed

  • Upgrading a paid plan did not take the payment. The plans page sent subscribers to a new Checkout session; it now changes the existing subscription (backend beta.65) after a short confirmation that says what is charged. If the bank declines the card or asks for 3-D Secure, Stripe's invoice page opens and the upgrade applies once it is paid.

Added

  • Past-due banner actions: "Pay now" retries the unpaid invoices on the card on file (or opens the invoice page if the bank refuses), "Update card" opens Stripe's card-only page; coming back from it retries the unpaid invoices on the new card.

v0.2.0-beta.70

Dashboard

Added

  • One select for the whole product (components/ui/select.tsx, on Radix Select). With a mouse it opens a styled listbox — the dashboard's panel, hairline border, highlighted row, a tick on the chosen option, keyboard and typeahead, groups, icons and descriptions per option; on a touch screen the same trigger opens the platform's own picker. Two sizes (md for forms, a sm pill for toolbars and table rows). All 33 native selects moved to it: team data access, monitoring, logs toolbar, env, project and server settings, new project and new server, domains and DNS, API keys, SSO, database backups and connections, the data browser, admin users and the site editor. Protected branches show a lock icon instead of an emoji.

Fixed

  • The open list of a select ignored the theme: it was drawn by the browser and OS, light in dark mode on some systems.
  • Page header descriptions that contain a skeleton or a link no longer nest a div inside a p (a hydration error on the overview).

v0.2.0-beta.69

Dashboard

Changed

  • Every dashboard page is built like the project detail screen. A plain page header (breadcrumb on sub-pages, title, status badge, one-line description, a mono meta line, pill actions on the right), underline tab strips where a page has parallel views, hairline row lists instead of card grids, key/value rows, toolbars on top of lists, and settings as sectioned forms with the label on the left and the control on the right. The anatomy lives in components/dashboard/PageKit.tsx and components/dashboard/SettingsParts.tsx. - Overview: counts in the header, projects, activity, onboarding and quick actions as rows. - Projects, servers, databases, domains: row lists with a status dot, mono facts and a … menu. Server detail and database detail gain ?tab= tabs (server: overview, workloads, network, activity, details; database: overview, projects, backups, settings). - New project and new server: step strip and settings sections; server sizes as a radio row list. - Monitoring, alerts, activity, team, billing: stat cards, one toolbar card for filters and charts, tabs on alerts, member rows with their controls on the right, billing as sections with a sticky index. - Account settings (8 tabs) and the admin panel in the same layout.
  • The projects list is a single row list; the cards/table toggle is gone.

Fixed

  • A small select rendered its value as dots (team data-access, monitoring project picker): the shared select padding overrode the compact size.
  • The top-up row in billing repeated the section's description as its label.

v0.2.0-beta.66

Platform & API

Fixed

  • Build failed after merging beta.65 into master. #153 and beta.65 each declared LIVE_SUBSCRIPTION_STATUSES in stripe.service.ts (one a Set, one an array), which git merged without a conflict. One Set now serves both — the checkout guard and plan changes — and includes unpaid, so an organisation with an unpaid subscription pays or changes it instead of opening a second one.

v0.2.0-beta.65

Platform & API

Fixed

  • Plan upgrades never charged an existing subscriber. Every upgrade opened a new Checkout session, which created a second subscription beside the first (or failed on the card). POST /billing/change-plan now changes the existing subscription in place: an upgrade is invoiced pro-rata and charged to the card on file at once (always_invoice + payment_behavior: pending_if_incomplete), so a declined card or a 3-D Secure challenge leaves the old plan untouched and returns Stripe's hosted invoice page as payUrl; paying it applies the upgrade automatically. Downgrades apply at once with a credit on the next invoice. /billing/checkout answers 409 ALREADY_SUBSCRIBED for an organisation that already has a live subscription.

Added

  • Collecting past-due payments. POST /billing/pay-outstanding retries every open invoice on the card on file and returns the hosted invoice page of the first one the bank refuses; the organisation is marked active when all are paid. POST /billing/payment-method opens a Stripe page that only replaces the card, and on customer.updated with a new default card the unpaid invoices are retried by themselves.
  • The payment-failed email links straight to the invoice ("Pay invoice") instead of the billing page, and invoice.payment_action_required (3-D Secure) now sends its own "confirm your payment" email without marking the organisation past due.

v0.2.0-beta.64

Platform & API

Fixed

  • Managed server sizes were priced for the wrong location and ignored stock. The size list and the quote took Hetzner's first listed location (Falkenstein) whatever region was chosen, and offered types Hetzner has sold out of; creating and resizing then picked a type with a different rule. In Nuremberg or Helsinki an XS was shown and billed at cx23's €5.49 although cx23 can only be provisioned in Falkenstein — the create failed, or another, pricier type was started. Listing, quoting, creating and resizing now share one resolver (resolveTier) that uses the chosen location's price and only types in stock there (from /datacenters), the quote carries the exact type, and the server is created with it — what is shown is what is billed is what runs. Two tiers that resolve to the same type are listed once. Regions with nothing in stock are marked unavailable. The catalogue is cached for five minutes.
  • Every OS image was listed twice (its x86 and arm builds under the same name). Managed sizes are x86, so only x86 images are offered.

v0.2.0-beta.68

Dashboard

Changed

  • The rest of the dashboard, screen by screen. Modals share one shell (.dash-modal, dialog roles, mono eyebrow, pill footer); toasts are monochrome cards with a status-coloured icon; menus, the command palette and keyboard shortcuts share .dash-menu and .dash-kbd; empty states are one EmptyState ([ LABEL ], a line, one action) and skeletons shimmer in the real layout's shape; native selects, switches and segmented controls match the inputs. Project detail has an underline tab strip, compact deployment rows with a quiet mono pipeline, a sectioned settings form with a sticky index, and hairline lists for env vars, domains, cron, workers and notifications. Database detail and the Data Browser, server detail, both terminals (black, off-white, green only for the prompt) and the site editor's chrome follow.
  • Docs, section by section. Titled code panels with a copy button, hairline parameter tables, callouts as hairline cards, # anchors on every heading and deep links that open the right section; the mobile menu traps focus and closes on Esc.
  • Social previews in the new look. A new default card and one per page (features, pricing, apps, comparisons, blog posts, app pages…), rendered with Inter and Geist Mono over the hero's light.
  • Small labels are easier to read. Muted and eyebrow greys raised to ≥4.5:1 on every surface they sit on.

Added

  • The dashboard on the homepage. A section with three real screenshots (overview, project, database) behind tabs, captioned as sample data.

Fixed

  • Copying a masked connection string or password copied the dots.
  • The notification settings toggle was invisible when on, in dark mode.
  • English strings in the Turkish dashboard (terminal and shell titles, Pushify URL and SSL badges, Nginx settings, site editor, data browser labels).
  • Blog and app pages showed the generic preview image instead of their own.
  • The homepage headline was reported as painted late because it faded in from transparent; it now only slides.

v0.2.0-beta.67

Dashboard

Changed

  • The site, redesigned in the language of developer.x.com. A #0a0a0a canvas, hairline rules, bracketed monospace eyebrows ([ PRICING ]), Inter 500 headings with light 48px section titles, pill buttons, and colour kept for status only — green means live. The homepage opens under a light cone drawn with a conic gradient (the same technique developer.x.com uses, measured from its page), shows fifteen real deploy-log cards drifting in three rows, compares a month's bill against Vercel and Railway from their published prices, and ends on a faint mark lit from below. Every public page — features, pricing, sites, apps, domains, open source, about, partners, comparisons, alternatives, deploy button, pushify.yaml, status, guides, framework pages, docs, blog, changelog, legal, 404 — is built from the same kit (components/landing/MarketingKit.tsx, styles in app/marketing.css): one section under a hairline, an eyebrow and a title, open grids divided by lines, numbered steps only where order is real, one designed object per page.
  • Copy cut to one or two lines per item. Long explanations moved into FAQs and expandable rows; nothing was dropped. Icons stay on each page's headline block only.
  • Pricing rebuilt. Four plans side by side with thin prices and short limit lines, the recommended plan lit, Enterprise on one row, the two bills (platform plan, infrastructure credits) in two short columns, a hairline comparison table, then the cost estimate and the FAQ.
  • The dashboard in the same language. Black canvas and white/8 hairlines, bracketed labels, one page-title style for every page, pill buttons, ink instead of indigo, monochrome charts and gauges (colour only past a real threshold), category colours removed from the marketplace and billing.
  • Sign-in and sign-up under the same light, with the site's form and button styles.
  • Dark by default everywhere. The public site is dark-only for now (the navbar's theme button is gone); the dashboard opens dark and keeps light and "system" for whoever picks them. Visitors who never chose — whose stored preference was the old system default — move to dark once.

Added

  • A page-change progress bar. A 2px ink line with a soft glow runs across the top on every navigation, NProgress-style: it starts on the click, creeps towards 90%, completes when the new page renders, and stays on screen for a moment so even a prefetched page reads as loaded. Hash-only changes don't start it; a click that never navigates gives up after ten seconds.
  • **Blog posts render *italics***, and quoted front-matter titles lose their quotes.
  • Marketplace descriptions render their markdown (lists and bold) on /apps/[id] and in the dashboard.
  • The open-source page lists the CLI repository and the one-command self-host install.

Fixed

  • Claims the code doesn't back, removed: "in seconds" in the hero and features, "in under 60 seconds" on framework pages, "Start Pro trial" (there is no trial), "Most popular" (now "Recommended"), invented timings in mockups, "No credit card required", "Instant rollbacks", "we use several of them ourselves", a star count, "Agencies use Pushify", a one-business-day reply promise, "migrations" for Laravel, and a CLI mockup that uploaded artifacts the CLI never uploads. The Coolify comparison now shows Pushify's one-command self-host install. pushify.yaml's reference now matches the parser: an invalid file is a warning, synced resources are only added or updated on production deploys, volume names are 31 characters, up to 5 workers, cron timeout defaults to 120s.
  • Billing showed "Personal" after a reload instead of the organisation's name.
  • The dashboard breadcrumb was English-only and read "Dashboard" on Domains and Admin; it now uses the sidebar's translated names.
  • Relative times on Activity, Cron and Registries were English in Turkish ("12m ago"); they now use the translated form.
  • Legal pages said "Son güncelleme" in English too.

Added

  • The Domains page says what happens after the search box. It was a heading, a search field and then the footer — a page in the main navigation with ninety-five words on it. It now lists the ten TLDs one keyword search covers, and what the registrar integration actually does: registration for up to five years with WHOIS privacy on from the start and auto-renew under your control, the records and the certificate written in the same step when you point it at a project, A/AAAA/CNAME/MX/TXT/SRV/NS editable in the dashboard, transfers in that include a year of renewal, and transferring out by unlocking the domain and reading your own auth code.
  • Real screenshots of the product, on the landing page. The marketplace and Site Studio sections were illustrated with drawn mockups only. They are good mockups, but they prove the design, not the product — a visitor cannot tell a screenshot from a picture of one. Both sections now carry a shot of the running app, with its real app names, versions, memory figures and template counts. One file per theme, swapped in CSS because dark mode here is a class on <html> rather than a media query; the hidden one is never downloaded.
  • The changelog archive is paged. It rendered every older release in one 463 KB document, some ninety entries deep. Thirty releases a page now, the same size as the front page, with newer/older links above and below the list and every page listed in the sitemap — a page a crawler can only reach by clicking through a pager is a page it mostly does not reach.
  • See what autoscaling decided before you trust it. Project settings gained "Only report what it would do" — the decision runs and is recorded, nothing changes — and a list of recent decisions underneath, each with the CPU reading behind it and marked when it was not applied. Watch for a few days, then decide whether the thresholds suit your traffic.
  • Autoscaling documented. The Monitoring & alerts page now explains what makes the count move: one container at a time, up above 70% average CPU after three minutes, down below 30% after ten, never on fewer than three readings — and why changing the bounds applies at once while the thresholds wait.
  • Autoscaling in project settings. A checkbox and a minimum/maximum, next to the replica count — which then shows what is running right now rather than what someone typed, and says so. Available on Pro and above.
  • Documented what Pushify watches, and what it keeps. A Monitoring & alerts page in /docs: every condition that sends an email and the exact threshold behind it (three failed checks before "not answering", 90% memory for five minutes, 90% CPU for fifteen, disk checked hourly, certificates at 14 and 3 days), why those windows exist rather than alerting on the first reading, who receives them, how many days of logs each plan keeps, and what a backup interval actually costs you.
  • A guide for private images and compose stacks. /docs now explains the three ways code reaches a server and the credentials each needs: which scope a registry token actually requires (read:packages on GitHub and nothing else; a read-only token on Docker Hub; read_registry on GitLab — a token that can also write is one that can replace your images if it leaks), what deploying a ready image does and does not change, and how a compose stack behaves — including that only the served service is published, so a file mapping 5432:5432 for its database does not end up putting that database on the internet.
  • A single sign-on setup guide. /docs gained a Single sign-on page, because the hard part of SSO is entirely on the provider's side and nobody can guess it: what to create in Okta, Entra ID and Google Workspace, which field is the issuer for each, where the client secret hides (Entra shows the Value once and the ID next to it, which is the wrong one), and why the redirect URI has to match character for character — a mismatch fails at the very end of sign-in with a message from the provider, so it reads like their problem. It also says to test the connection *before* turning on "require single sign-on", since a wrong connection plus enforcement locks the owner out too, and lists the five errors people actually hit with what each one means.
  • Choose how much data you are willing to lose. A database's backup panel takes an interval — hourly, 6 hours, 12, daily, 2 days, weekly — instead of the fixed daily backup, and says plainly what it costs: *"If the server is lost, you lose up to 6 hours of writes."* Shorter intervals are a paid plan's; asking for one below your plan's floor is refused with what to upgrade to.
  • Servers show how full their disk actually is. The server list and detail page report the real usage from the hourly check (45% / 80 GB), not just the size, and the list turns the figure amber past 85%. A full disk takes every container on the box down together, databases included, so it is worth seeing before the email arrives.
  • Backups say whether a copy exists off the server. A finished backup now carries a badge: *Off-site* when a copy is kept somewhere other than the server the database runs on, *On this server only* when it is not — because a dump beside the data survives a dropped table and nothing else.
  • Single sign-on, and the audit log as a file. Settings → Single sign-on configures the organization's identity provider (issuer, client ID and secret, the email domains it signs in) and shows the redirect URI to paste at the provider. "Require single sign-on" turns off passwords, GitHub and Google for those domains. On the login page, typing an address in such a domain replaces the password field with one button; where SSO is available but not required, it appears beside the password form. The activity page gained Export CSV — the filtered log as a file, with the IP each action came from.
  • Deploy a repository as a Docker Compose stack. Project settings take the path of a compose file in the repository; the project then deploys that stack instead of being built. A second field names the service nginx serves when more than one publishes a port. Left empty — the default — nothing changes: most repositories carry a compose file meant for local development, so one is never picked up on its own.
  • Private registries, and deploying from an image. Settings → Private registries stores a registry host, username and token for the organization (the token is write-only — it is never shown again, only replaced). Project settings gained a Docker image field: fill it in and the project deploys that image instead of building the repository, and the rest of the build settings are marked as unused.
  • Logs explorer: filters and a real download. History mode filters by time range (last hour / 6h / 24h / 7 days / everything) and, when a project runs more than one container, by container — replicas, workers and the staging copy each show up by name, and lines are labelled with the container they came from. Download now asks the server for the whole result as a .log file instead of saving the 500 lines on screen, and the retention line states the days your plan actually keeps instead of a fixed "7 days".
  • Replicas setting. Project settings take the number of containers to run behind nginx (1–10); nginx spreads requests across them.
  • Staging on the project page. Settings take a staging branch; the overview then shows a staging card with its URL, a "Deploy staging" button and "Promote to production" (the commit staging runs, rebuilt with production's variables).
  • Monitoring line on the project overview. Says whether the app is answering (with its response time) or not (with the status code or error, and since when). Every deployed project is watched now, so it shows without configuring anything.
  • Zero-downtime deploys: `scripts/jenkins-deploy.sh`. The Jenkins job deleted node_modules and .next where the live site ran, so the dashboard answered "Internal Server Error" for the whole install + build. The script builds in the workspace, assembles the standalone build as a release under /var/www/pushify-frontend/releases/<time>, tries it on a spare port (a release that doesn't answer never goes live), switches current to it atomically and has pm2 restart the two cluster instances one after the other. The first run takes over the app from ecosystem.config.js (name, port and env kept); the last 3 releases stay for rollback. Verified in a container: 202 requests during a full redeploy, 202 answered.
  • Connect a database read-only. The connect form on the database page has a Read & write / Read only choice (not offered for Redis); read-only connections carry a badge. The backend gives them a database user that can only read.
  • After a password reset, the connected projects are named. The new-credentials dialog lists the projects connected read & write — they keep the old password until they redeploy — with a "Redeploy them now" button that starts their deploys.
  • Database → Connected projects. Connect a project to a database (and choose the variable, DATABASE_URL by default) or disconnect it, right on the database page — the API existed but nothing in the dashboard used it. Projects on another server without external access are flagged. Applies on the project's next deploy.
  • Database connection details show the address apps use ("From your apps on this server", the container address injected into connected projects) above the external one.
  • Project → Settings → GitHub access. Shows the repository, whether Pushify can read it and through what (GitHub App on @account, or a GitHub account), and — when it can't — fixes it in place: Install GitHub App on @owner and Connect / Reconnect GitHub account, both returning to this tab when GitHub is done. Warns when only your own account can read the repo (deploys you start work, pushes don't) and when your connection can only see public repositories. English + Turkish.
  • "Repository access" deploy failure category with a hint pointing at Settings → GitHub access (deploy summary and admin histogram).
  • Admin panel at `/admin` for platform operators (accounts listed in the backend's ADMIN_EMAILS, 2FA required). Four views: Overview — headline numbers, the sign-up → verified → project → deploy → live → server → paid funnel, deployments by status, resources, plans, and 30-day sign-up/sign-in strips; Users — searchable, sortable list with plan, projects · deploys (failed) · servers · DBs, last seen and joined; User detail — organisations, projects, servers, databases, active sessions, and a single day-grouped timeline that interleaves sign-ins, deploys (with the error inline when one failed), created resources and dashboard actions; Activity and Sign-ins — platform-wide, paged. The sidebar shows the link only when /auth/me reports isPlatformAdmin; a non-operator opening the URL gets the normal 404 page, and an operator without 2FA is told exactly what to turn on. English + Turkish.
  • Dashboard-styled 404 (app/(dashboard)/not-found.tsx) for notFound() thrown inside the dashboard — previously the marketing 404, header and all, rendered inside the dashboard shell.
  • Admin: "Why deploys fail" panel on the overview — last 30 days of failed deploys by cause, share, projects affected and the latest message; causes blamed on Pushify are highlighted.
  • Admin: stuck-user filters on the users list — never verified email, no project yet, only failed deploys.
  • Team → Invite member modal redone. The email field no longer overlaps its icon (.input overrode the padding utility; now pl-10! like the other icon inputs). Roles are three cards with icon + description instead of a native select, the note is clearly optional, errors use the shared alert box, Enter submits, and the button stays disabled until the address looks like an email. Description says the link works for 7 days.
  • Marketing copy only claims what the code does. Removed or reworded every unbacked item: Laravel "automatic migrations / Horizon / scheduled tasks / Redis", Node "PM2", Python "Celery / migrations / virtualenv", Next.js "automatic ISR", managed-database "connection pooling", the unmeasured "<60s" deploy claims (hero stat, how-it-works, Next.js page), and the three anonymous testimonials (section deleted). Docs now list hetzner as the only managed provider.
  • Billing: resume a scheduled cancellation. When a subscription is set to end at the period end, the plan card says so with the date and offers "Keep my subscription" instead of the cancel link (the API and hook existed; no UI called them).
  • Settings → Notifications tells the truth. "Deployment alerts" now does what it says (backend emails on a failed production deploy and on the recovery after it); the "Product updates" toggle, which nothing ever read, is gone.
  • No personal name on the site. Blog posts no longer carry a named author (they're published as Pushify, and the BlogPosting schema points at the organization); the About page's Founder row and note and the Organization schema's founder are gone.
  • Misc. Google Analytics loads only for builds that talk to Pushify's own API (NEXT_PUBLIC_API_URL on api.pushify.dev) or when NEXT_PUBLIC_GA_ID is set, so a self-hosted instance never reports to Pushify's property; Bitbucket is no longer offered as a git provider (the API refuses it and nothing ever integrated it).

Fixed

  • The page no longer changes language after it has loaded. A Turkish visitor saw English paint and then switch, about 85ms later, on every reload — the pages were prerendered in English and the language lived in localStorage, which the server cannot read. middleware.ts now decides it from a cookie, or from Accept-Language on a first visit, and the root layout renders the page in it. Measured: the navigation reads "Özellikler" from the first frame, 51ms cold and 26ms warm, with no console errors — nothing changes under the reader any more. Two things were in the way. The locale store could not carry the answer: zustand v5 serves getInitialState() as the server snapshot, so every useLocaleStore(...) during SSR answered en however carefully the request had been worked out — the language now travels down the tree as React context, which is per render. And the Turkish dictionary was a lazy chunk, which cannot be there in time for the first client render; it is served from /i18n/tr as an immutable file instead, requested only by Turkish visitors, fetched once and reused for every page. Inlining it in the HTML would have cost 55KB gzipped on every load and traded a text flash for a slower first paint. The marketing pages are server-rendered per request now rather than prerendered. Measured cost: TTFB 1.4–2.4ms → 4.5–5.0ms.
  • Every link in the navbar and footer reloaded the whole site. They were plain <a> tags, including the ones pointing at our own pages, so the router never ran: each click threw away the application and built it again from the HTML. Measured — one document request per click, where client-side routing makes none. The visible cost was the language. The locale lives in the browser and the Turkish dictionary is a lazy chunk, so a full reload meant a Turkish visitor watched the site render in English and then switch — on every page change. Internal links go through next/link now (desktop nav, mobile drawer, footer, and the Terms page's own links): zero document requests, the page is already prefetched, and the text never changes language again. The flash remains once on the very first load, roughly 100ms, because the pages are prerendered as English and the browser's language is only known on the client.
  • `<html lang>` said English to a Turkish visitor until React had hydrated. The boot script that already sets the theme before first paint now sets the language too, from the same stored value the app uses.
  • Most of the site's descriptive text was too faint to read comfortably. Measured across fourteen pages in both themes: the muted grey that carries nearly every card description, caption and footer line sat at 2.61:1 on the dark canvas, against the 4.5:1 floor for body text. It is 5.2:1 now, still a step below the secondary tone so the hierarchy survives. The light theme never overrode the status colours at all, so it used the ones picked for a near-black background — the green "Live" and "Healthy" labels measured 2.2:1 on white. Same hues, dark enough to read. 190 elements fixed; what remains are 8–14px labels drawn inside the mockups.
  • Nine feature cards in a four-column grid. The last one stood alone on a row of its own on /features. Three columns, three rows.
  • The pricing page was a spinner in an empty third of a screen until the API answered — and stayed that way if it never did. Three card outlines in the shape of the real plans now hold the layout while the numbers arrive.
  • The site claimed an app it does not have. The marketplace was advertised with Grafana in five places (Partners, the apps page's description, and the Site Studio banner inside the product) — there is no Grafana template. Replaced with Uptime Kuma, which there is.
  • "24+" and "20+" outlived the pass that removed them. Eleven more places still said "24+ apps" (there are exactly 24) and "20+ frameworks" (the Frameworks section says 26 and cites where it counted), including the homepage's structured data.
  • Every page but the homepage shared without a preview image. Next.js replaces openGraph wholesale when a page declares one — it does not merge into the root layout's — so the twenty-six pages that set their own title and url had silently dropped the image along with it. Sharing Pricing, Features, a comparison or a blog post on LinkedIn, Slack, WhatsApp or Discord produced a bare grey card. The image is now spelled out per page, twitter included.
  • The Open Source page named a repository that does not exist. The card read pushifydev/pushify, which is a 404 — on the one page whose job is to prove the project is open source. It lists the two repositories that do exist, each linking to itself. The Star and Fork row went with it: neither carried a number, so it read as something that had failed to load.
  • The Open Source page had no `h1` at all. Its only heading was the section's h2, so the page had no title for a screen reader or a crawler to take. A section header can be the page title when the page is that one section.
  • The changelog scrolled sideways on a phone. A release note mentioning GET /integrations/github/app/installations[/:id/repositories] is one unbreakable token 402 pixels wide; on a 390-pixel screen it dragged the whole page with it. Code spans break anywhere now, so no future entry can do it either.
  • Three meta descriptions were cut off in search results (Sites, the Heroku comparison, pushify.yaml ran to 189, 215 and 172 characters against Google's ~160), and the app pages built theirs by putting the catalogue blurb first — the half that is identical on every site that lists the app. The Pushify sentence comes first now and the blurb is what gets trimmed.
  • "Under 60 seconds" survived in five places. The unmeasured deploy-time claim was removed from the marketing copy earlier but stayed in the Features description and its structured data, in the Next.js deploy page's metadata, and in the Vue page's steps in both languages.
  • Marketing site: fixes found by actually looking at it. The footer read *"Built with for developers"* on every page — the heart the string was named after had never been added, and the Turkish version (*"Sevgiyle geliştiriciler için"*) was a sentence with no verb. Between 768px and roughly 1100px the navbar wrapped "Open Source" onto a second line and bent the bar out of shape; the menu now waits for the width it needs and no label wraps. In the hero, the dashboard card behind the terminal was sliced down the middle, leaving orphaned fragments ("47s", "39s") floating beside it — it now peeks upward only, as two stacked windows, with a single row that nothing can cut in half. The footer's Resources column had grown to eleven links and stood twice as tall as its neighbours; the five comparisons are their own column now and the row is even. "0 vendor lock-in" read as a broken sentence inline and is now "No vendor lock-in" (and was untranslated in Turkish). About printed the same tagline the site footer already carries, a few hundred pixels above it.
  • Two claims corrected — both understated. The site said "20+ frameworks" while the buildpacks detect 26 across 8 languages, and "24+ marketplace apps" when there are exactly 24. An exact number reads as more credible than a vague plus, and claiming a plus that is not there is the kind of small inaccuracy that costs trust when someone counts.
  • Builds no longer depend on reaching Google Fonts. next/font/google downloads Inter and JetBrains Mono at build time, so a build without a working connection to fonts.googleapis.com failed outright — the likely cause of CI's intermittent Build failures (the same commit passed and failed). Both fonts are now in app/fonts (from google/fonts, SIL OFL, licences included), variable weight 400–700, Latin + Latin Extended-A so Turkish renders in the font, via next/font/local (still self-hosted, preloaded, with fallback metrics). Inter 62 KB, JetBrains Mono 34 KB. Verified: builds with networking off; the previous version fails the same way.
  • CI runs again. The workflow only triggered on main (the default branch is master) and its dashboard-e2e job read secrets in a job-level if, which GitHub rejects — so nothing ever ran. It now runs on master, main and release/**; the credentials check moved to a small job whose output gates the dashboard e2e; unit tests (npm test) joined the pipeline. Lint passes with 0 errors: the new React Compiler rules (set-state-in-effect, static-components, refs) and no-explicit-any report as warnings for now (125 left to burn down), and MetricsSection's require('recharts') became a normal import.
  • Brand assets match the logo again. The favicon set (app/icon.svg, PNGs, favicon.ico, apple / maskable icons) was still the retired indigo mark, the Organization schema's logo was an even older cyan "arrow" SVG, and og-image.png showed a third, long-gone identity (diamond mark, cyan) with an unmeasured "Deployed in 12s" line. All now follow components/logo.tsx: a neutral-900 tile with a white P — the SVG favicon flips to light-on-dark in dark mode like the site logo. New og-image.png (monochrome, the site's own headline, no speed claims) is generated by scripts/gen-og-image.mjs with Inter + JetBrains Mono; scripts/gen-favicons.mjs also writes logo-512.png, now the schema logo. Favicon URLs carry ?v=2 so browsers drop the cached purple icon; manifest theme colour follows. Meta, Open Graph, Twitter and SoftwareApplication descriptions no longer claim "under 60 seconds" / "zero config".
  • Nginx settings: errors are shown, and Custom location blocks explain themselves. Saving showed nothing when the server refused (the modal just stayed open); the reason is now a toast. The Custom location blocks help text says what they are — the server's own Nginx in front of the app, own servers only — and that a block without proxy_pass / return serves the server's files, not the app (EN/TR).
  • Clearing a field in a domain's Nginx settings now removes it. The modal sent cleared or switched-off fields as undefined, which never reaches the server, so emptying Custom location blocks, the proxy port or the headers, or turning rate limiting / caching off, kept the old value. They are sent as null now (backend removes them).
  • DNS instructions named the wrong record for an apex domain. For example.com the "Name" column said example (the first label) instead of @, so following it created example.example.com and the apex never resolved. Names are now computed from the registrable domain, including two-part suffixes like .com.tr and .co.uk. The panel also lists the www / apex twin's record ("add this one too so www.example.com works — it redirects to example.com"), is translated (EN/TR), and can be opened on verified domains too.
  • Re-check SSL on a verified domain. Verified domains get a button that re-runs Nginx + SSL — for a www record added later or a certificate that failed — instead of needing a redeploy.
  • Switching GitHub accounts actually switches. GitHub now shows its account picker when connecting (backend), "Change account" no longer disconnects first (cancelling the picker keeps the current connection), and New Project opens on the list you chose last — right after connecting an account, that account's repositories instead of the App's.
  • Connecting GitHub without the App is possible again. Once a GitHub App was configured, New Project only offered "Install GitHub App" and hid "Connect to GitHub" when no account was connected; the OAuth connection is back as the secondary option (and from the App picker). A connection that can only see public repositories now offers Reconnect first and no longer hides the repository list.
  • GitHub sends you back where you started. After the OAuth screen or the App installation the dashboard always went to New Project; it now returns to the page that started it (e.g. a project's Settings tab). Only dashboard paths are accepted.
  • First visit ignored the browser language. The locale store checked for a saved preference *after* creating the store, but zustand's persist writes the default (en) to localStorage the moment it hydrates — so the check always found a value and browser detection never ran; every new visitor got English regardless of their OS language. The check now happens before the store is created. A Turkish browser opens in Turkish; anything else still gets English. (Theme was already right: it follows the OS until you toggle it.)
  • /pricing came up dark for Turkish visitors. Two things stacked: toLocaleString() without a locale printed 3,000 on the server and 3.000 in a tr-TR browser, so React reported a hydration text mismatch and regenerated the page on the client — and when it does that it rewrites <html class> from its own props, which never included the light/dark class the boot script had added, so the page fell back to the dark defaults. Fixed at both ends: pricing numbers are pinned to en-US; the first client render is forced to the default dictionary so it always matches the server HTML (the real locale shows after hydration, via the new AfterHydration component, which also re-applies the theme class as a guard against any future mismatch). Reproduced and re-verified with a fresh Turkish light-mode browser on /pricing, / and /login.

v0.2.0-beta.63

Platform & API

Changed

  • Emails in Pushify's new look. One layout for every transactional email: a white card on a grey page, the black P mark, a mono [ LABEL ] per email with a small status dot (green recovered, amber warning, red failed), a hairline details table, a mono block for errors, one black pill button (with an Outlook fallback) and a quiet footer. Tables and inline styles only, 560px, light by design so forced dark modes stay readable. The seven hand-built alert emails, the notification emails and the admin email now use it; wording, subjects, links, EN/TR copy and unsubscribe/settings links are unchanged.

Fixed

  • Alert emails put project, database, server, container and domain names into HTML unescaped. They are escaped now.

Added

  • Growth signals and a deploy window for the HQ agents. GET /api/v1/ops/growth (same OPS_READ_TOKEN, same 404-when-unauthorised rule) gives pushify-hq's growth agent the new-user funnel for the last 7 and 30 days — registered, verified, created a project, tried to deploy, deployed successfully, paid — plus the users of the last 14 days who have a project but no successful deploy, grouped by what kind of project it is (image, compose, Dockerfile, buildpack) and by why their newest deploy failed (the existing classifier, with whose fault it is), the users who never created a project, and cancellation reasons with recent comments. Aggregates only: no ids, names or addresses leave, and cancellation comments — customer-written text — go through scrubSecrets, which now also removes e-mail addresses. GET /api/v1/ops/signals?since=<iso> adds how many customer deployments started after that moment, failed, and failed for a Pushify-side reason, so the operations agent can compare the half hour after a Pushify release with the day before it; windows older than the 24-hour signal window are ignored. 5 tests (the route-table walk covers the new route's token check).
  • Operational signals for an operations agent, read-only and scrubbed. GET /api/v1/ops/signals gives pushify-hq's on-call agent what it needs to notice a problem and nothing more: deployments in the last 24 hours with failures grouped by cause and by whose fault each cause is (Pushify, the server, or the customer's own code — reusing the existing classifier), projects whose latest deploy failed at least twice, sites down for more than five minutes, servers in error, with failed setup or over 85% disk, and databases in error. It is a machine endpoint, separate from /api/v1/admin on purpose: that one is for a person with 2FA and never accepts a key; this one accepts exactly one bearer token, OPS_READ_TOKEN (32+ characters, compared in constant time), and answers 404 — like an unknown URL — when the token is missing, wrong, or not configured at all. What leaves is ids, states, counts and times: no project names, emails, IPs, repositories, environment variables or logs, because the consumer hands it to a language model. Error text goes through scrubSecrets first (lib/ops-scrub.ts), which removes clone-URL credentials, provider tokens (GitHub, GitLab, Stripe, Slack, AWS, sk-…), JWTs, KEY=value pairs whose name says secret, PEM blocks and long opaque strings — deliberately over-eager, since a redacted word in a diagnosis costs nothing and a leaked token does. Responses are no-store. 17 tests: the route table is walked so a route added later is covered by the token check too, and each scrubber case asserts the secret is gone while an ordinary error stays readable.
  • Autoscaling can watch before it acts. The thresholds are reasonable guesses until they meet a real workload, and finding out whether they suit yours should not require letting them loose on production first. "Only report what it would do" (projects.autoscale_observe_only, migration 0059) runs the whole decision and writes it down without touching a container. Every decision — carried out or not — is recorded with the CPU reading behind it (project_scale_events) and listed in project settings, so after a few days the question "are 70% and 30% right for this app" has an answer instead of an opinion. GET /projects/:id/scale-events returns the last 50.
  • Autoscaling: containers follow the load. A project can set a minimum and a maximum (migration 0058: projects.autoscale_enabled/min/max, Pro and above) and Pushify adds or removes containers between deploys instead of leaving the count where someone last typed it. CPU is the signal — more replicas do not help an app that leaks memory, and request-driven memory shows up in CPU anyway. The rules are deliberately asymmetric, because the failure mode of autoscaling is not being slow, it is thrashing: one step at a time, add above 70% average CPU after a 3-minute cooldown, remove below 30% after 10 minutes, and never on fewer than three readings. The thresholds are far enough apart that a step cannot undo itself — adding a third container to two at 70% lands near 47%, nowhere near the scale-down line. Changing the bounds takes effect at once, ignoring cooldowns and readings: the range is an instruction, the thresholds are a heuristic. Two things make it safe rather than merely clever — a new container is started from the spec the *deploy* recorded (migration 0058: deployments.run_spec_encrypted, encrypted because it holds the environment), not from settings read now, so a replica can never drift from its siblings; and scaling down removes the container from nginx and reloads *before* stopping it, or requests in flight would be dropped. The primary container is never removed. An app without a domain is served on <server-ip>:<port> by a site of its own rather than by the project vhost, so scaling updates that instead — otherwise a new replica would receive nothing and a scale-down could stop a container nginx was still routing to. 20 tests.
  • A database's backup interval is its own, and it says what it costs. Automatic backups ran every 24 hours for everyone, hardcoded — so the worst case was always a full day of lost writes. Each database now carries an interval (migration 0057: databases.backup_interval_hours, still 24 by default), and the plan sets the shortest one allowed (minBackupIntervalHours: a day on free, 12 hours on hobby, 6 on pro, hourly on business and enterprise). The backup pass runs every 15 minutes instead of hourly, or an hourly interval would in practice mean two. A database that has never been backed up is due at once rather than waiting out its first interval — that is the window with nothing to restore from at all. 9 tests.
  • Someone is told before the app falls over. CPU and memory have been recorded for every container every fifteen seconds since metrics existed, and nothing ever read them — an app sitting at its memory limit was minutes from being OOM-killed into a restart loop, and the first anyone heard was the health check reporting it had already stopped answering. A container over 90% of its memory limit for five minutes, or over 90% CPU for fifteen, now emails whoever keeps deployment alerts on, with a second mail when it comes back down and how long it lasted (migration 0056: project_resource_state). The work is in not crying wolf: a container is at 100% CPU every time it starts and a garbage collector runs at 95% memory by design, so a reading has to *stay* over the line, and it only clears once it is well back under it — a value hovering on the threshold would otherwise alternate "problem" and "recovered" for ever. Three replicas at 95% are one email naming the worst container, not three. Memory is ignored where no limit is set, since the percentage is then of the whole host. State left behind by a paused project is dropped silently rather than announcing a month-long outage when it comes back. 15 tests.
  • A server filling up is noticed before the next deploy fails. Disk was checked only as part of a deploy, so a host filling up in between — image layers, logs, a growing database — was discovered when someone next deployed, by which time every container on it had been starved for however long that took. Every running server is now checked hourly (services/server-disk.service.ts), with an email at the existing warn threshold and a reminder once a day while it stays full, and the reading is on the server in the dashboard (disk_used_percent). It says to run docker system prune -af, which is what it usually is.
  • Customer database backups are kept off the server they were made on. A dump was written to /opt/pushify/backups on the database's own server — a copy of the data next to the data, which survives a dropped table and nothing else. Lose the disk, the server or the provider account and the backups go with it, which is the case people keep backups for. With DB_BACKUP_RCLONE_REMOTE set (an rclone remote: S3, R2, B2, a Hetzner Storage Box over sftp, or a crypt remote over any of them, which encrypts the dumps before they leave), each finished dump is streamed off the server and recorded (migration 0055: database_backups.offsite_path / offsite_status). The stream runs SFTP read → rclone stdin → the remote, so a 40 GB database needs neither 40 GB of disk on the control plane nor 40 GB of memory, and the storage credentials stay on the control plane — putting them on customer servers would mean one compromised box could read, or delete, every other customer's backups. A restore looks for the file on the server and falls back to the off-site copy, so a database can be restored onto a server that has never seen it. An upload that fails never fails the backup (a backup on the server is still a backup) and says so on the backup instead; the path itself is never handed to customers, only whether a copy exists. DB_BACKUP_REMOTE_KEEP_DAYS (30) prunes the remote copies, and deleting a database purges them — a deleted customer's data should not sit in the operator's storage indefinitely. 12 tests.
  • Single sign-on (OpenID Connect). An organization can connect its own identity provider (migration 0054: sso_connections, client secret encrypted, one per organization, owners only): the issuer, client id and secret, the email domains it signs in, and the role someone gets the first time the provider sends them. SAML is what this is usually called, but Okta, Entra ID, Google Workspace, Auth0 and Keycloak all speak OIDC too, and OIDC is verified with a JWT library rather than hand-written XML signature checking, which is where SAML implementations go wrong. The flow uses PKCE and a nonce, and the token has to pass all of it before anyone is signed in: iss is the configured issuer, aud contains our client id, the nonce is this login's, the provider says the address is verified, and the address is in a domain the organization claimed — that last one is what stops a tenant minting a token for someone else's address. The provider's document must name the issuer it was fetched for, so a redirect cannot swap providers. Sign-in state is encrypted and expires in ten minutes. A connection is only stored after the provider answers, so the first person to sign in is not the one who discovers the issuer is wrong. Enforced closes every other door for those domains — password login, registration, GitHub and Google all refuse, before an account is even looked up, so the refusal reads the same whether or not the address exists. 2FA still applies: SSO says who someone is, it does not waive a second factor the organization asked for. GET /sso/check, GET /sso/start, GET /sso/callback, and GET/PUT/DELETE /sso/connection. 23 tests.
  • The audit log as a CSV file. GET /activity/export writes the organization's activity — filtered the same way the page filters it — with the IP each action came from, which the list endpoint now returns too. A compliance review asks for the trail as evidence, and a dashboard is not evidence.
  • Static sites: a year for hashed files, and the CDN emptied on deploy. Every asset was served with expires 1h, so a visitor re-downloaded the whole bundle every hour and a CDN in front had nothing worth keeping. A build tool puts a content hash in the file name, and that exact file never changes: _next/static/, _nuxt/, _astro/, Vite's assets/, CRA's static/js|css|media/, Remix's build/_shared/ and any .js / .css / .woff2 whose name carries a hash now get public, max-age=31536000, immutable. Everything else keeps the hour, because the same name can mean new bytes after the next deploy, and HTML stays no-cache so a deploy is visible at once. With a year-long cache the edge has to be told when a deploy happens: where Cloudflare is configured, each deploy purges the project's own hostnames (purgeCachedHostnames, in batches of 30, never the whole zone — one project's deploy must not cost every other project its cache). The token needs Cache Purge; a purge that fails is a line in the deploy log, not a failed deploy. 5 tests.
  • Deploy a repository as a Docker Compose stack. A project can now set compose_path (migration 0053, with compose_service / compose_port): the deploy clones the repository as usual and then brings that compose file up as a stack from the checkout, so build: contexts and the config files beside it work exactly as they do locally. It is opt-in and never auto-detected — most repositories carry a compose file meant for local development, one that mounts the source and runs a dev server, so picking it up would quietly change how an existing project deploys. Ports are not the file's to decide: what deploys is a rewritten copy of the file (beside the original, so relative build: contexts still resolve) that publishes only the served service, on the host port nginx proxies (loopback-only on a shared runner), and drops every other service's published ports — ports: "5432:5432" on a database would otherwise put it on the internet, while services still reach each other by name inside the stack. Which service is served is the single one publishing a port, or the one the project names; anything ambiguous is refused with the list of services rather than guessed at. lib/host-access-guard.ts runs over the file first. Deleting the project brings the stack down and removes its network. Two things a stack does not get, and the deploy log says so rather than quietly ignoring them: Pushify's Workers and Scheduled tasks, which drive a single app container — declare those as services in the compose file. A redeploy takes the stack down and brings it back up, so unlike a built app it is not zero-downtime. 11 tests, plus an e2e: a two-service stack answers through nginx, the two find each other by name, the second service's own port is not published on the host, and a redeploy replaces the stack instead of piling a second one beside it.
  • Private registries, and deploying a project from an image. Two things needed registry credentials and had none: a Dockerfile whose FROM is a private base image, and running a ready image instead of a repository. An organization now stores a login per registry (migration 0052: registry_credentials, token encrypted, POST/GET/DELETE /registries, owners and admins only, at most 10). Before the build and any pull, the deploy signs in to each of them on the server — the password goes in through a here-doc, never as an argument where ps would show it — into a DOCKER_CONFIG directory of its own that is deleted when the deploy ends, so a shared runner keeps no token for the next deploy and the server's own docker config is never touched. A wrong token fails the deploy at the login with the registry's own message, not as "pull access denied" three minutes into a build. Setting projects.docker_image (ghcr.io/acme/api:1.4) makes a project deploy that image: it becomes a one-line FROM build context, so it gets the same blue-green switch, replicas, staging, volumes, nginx and runner isolation as a repository — and --pull, so redeploying a moved tag ships the new image. Such a project needs no repository; the local (no-server) fallback says to assign a server instead. 14 tests, plus an e2e against a real password-protected registry in the box: the deploy fails without credentials, serves the image with them, ships a moved tag on redeploy, and leaves no config directory behind.
  • Logs from every container, kept as long as your plan says. The collector tried -blue, -green and the bare name and stopped at the first container it found, so a project's replicas, its workers and its staging copy had no stored logs at all — the history simply skipped them. It now reads every pushify-<slug>* container on the server and stores each one separately (migration 0051: container_logs.container_name, indexed by project and time), and the logs explorer filters by container, by time range (last hour / 6h / 24h / 7 days / everything) and by stdout/stderr. Retention was a fixed 7 days for everyone and is now the plan's logRetentionDays — 3 days free, 7 hobby, 14 pro, 30 business, 90 enterprise — applied per organization, and the explorer says which one you have. GET /projects/:id/logs/containers lists the containers with logs, and GET /projects/:id/logs/export writes the search result to a .log file (up to 50 000 lines, oldest first, container and stream on each line) instead of the 500 lines on screen. 4 tests, plus an e2e that hits a deployed app, collects, finds the request by term and container, and gets nothing from a window before the deploy.
  • Run an app on more than one container. A project can set replicas (1–10, migration 0050): the blue-green switch brings up the first container as before and then starts the rest beside it, each on its own port, and nginx balances across them (least_conn, max_fails=2) — for domains and for a domain-less app's public port alike. A deploy replaces the whole set and retires the old slot's containers with it; scaling back down removes the extras on the next deploy. A per-domain proxy port override still points at one container, previews stay single-container, and a single replica renders exactly the configuration it did before. E2E: three replicas answer the same domain (different container ids), a redeploy keeps three, and scaling to one removes the rest.
  • Staging environments. A project can name a staging branch (projects.staging_branch, migration 0049): pushes there deploy a second copy beside production — its own container (pushify-<slug>-staging), port, volumes, domains (domains.environment) and auto subdomain — with the staging environment variables layered over production's, which the deploy finally reads (they were stored and ignored). Production's linked databases are deliberately not injected into staging, so a staging copy can't write the production database; give it its own DATABASE_URL under staging variables. Workers, cron jobs and the project's monitoring stay production-only. POST /projects/:id/deployments takes environment: 'staging', and POST /projects/:id/deployments/promote promotes it: the exact commit staging ran is built again with production's variables (public values are baked into a build, so it is a rebuild, not an image copy) and goes through the normal blue-green switch. 3 tests, plus an e2e that runs both copies at once and promotes.
  • Every deployed app is watched, and someone hears when it stops answering. Health checks only ran for projects that had a health_checks row, and the warning went to that project's notification channels — so an app that crashed after a deploy usually reached nobody. Now every active, awake project with a live deployment and a URL is called about once a minute (services/app-health.service.ts, state in project_health_state, migration 0048): three failed checks in a row count as down (one check is a restart, not an outage) and everyone with deployment alerts on gets an email, with a second one when it answers again, saying how long it was out. A health_checks row still customises endpoint, interval, threshold and auto-restart, and only those demand a 2xx — without one, any answer means the app is up (a 404 on / is a route, not an outage). GET /projects/:id/health-status exposes the state for the dashboard. Self-hosted installs whose apps sit on a private network can allow those addresses with PUSHIFY_ALLOW_PRIVATE_APP_URLS=true. 5 tests, plus an e2e that stops a deployed container and watches it go down and come back.
  • Deploys keep the API answering. scripts/jenkins-deploy.sh ran npm ci on every deploy — deleting node_modules under the running API, which loads parts of it on demand — and wrote the new build straight over dist/. Now npm ci runs only when package-lock.json changed, the build goes to dist.new and is moved into place in one step, and the script points out when pushify-api is a single fork process (ecosystem.config.example.cjs now runs it as 2 cluster instances, so pm2 reload restarts them one after the other; the worker stays single). Verified in a container with Postgres: 118 requests during a full deploy, 118 answered.
  • Warnings before an HTTPS certificate expires. Certificates renew by themselves until they don't (DNS moved away, port 80 blocked), and the first sign was a browser warning on the customer's site. Once a day the backup worker reads the certificate every custom domain actually serves (lib/cert-expiry.ts, whatever serves it — the server or a CDN), stores its expiry (migration 0047: domains.ssl_expires_at, ssl_expiry_notified_at) and emails the organization's billing contact 14 days before, and a last time 3 days before (or once expired), with the usual causes; operators get certificate.expiring too. A renewed certificate starts over. The auto-subdomain wildcard at WILDCARD_SSL_PATH is checked on the control plane (operators only, daily while due). 7 tests, plus an e2e check against a Pebble-issued certificate.
  • Read-only database connections are read-only. permissions: 'readonly' on a project's database connection was stored and ignored — the app got the owner's credentials. Such connections now get a database user that can only read (migration 0046: databases.readonly_username / readonly_password, encrypted): Postgres — SELECT on the public schema, including tables created later (default privileges) and read-only sessions; MySQL — SELECT, SHOW VIEW on its database; MongoDB — the read role on it. Created when the project is connected (so a problem shows there), or at the next deploy for older connections; grants are re-applied each time. Redis has no read-only mode here and is refused. The user survives a restore (the recreated public schema keeps USAGE for PUBLIC) and a password reset of the owner. E2E: a second project connected read-only reads the data, gets an error writing, and keeps reading after a restore and a password reset; MySQL / MongoDB read but can't write; Redis is refused.
  • Auto subdomains work on shared runners (Cloudflare). *.pushify.dev resolved to the control plane — the one host with the wildcard certificate — so apps on a runner got no subdomain at all, only http://<runner-ip>:<port>. With CLOUDFLARE_API_TOKEN + CLOUDFLARE_ZONE_ID set, every auto subdomain (and PR preview) gets its own proxied A record to the server that hosts it (lib/cloudflare-dns.ts), updated when the app moves and removed with the domain, the preview or the project; the runner then needs the wildcard (or a Cloudflare Origin) certificate at WILDCARD_SSL_PATH. Only records Pushify created (marked in the record's comment) are ever changed or deleted — a project named api can't move api.pushify.dev — and platform names (api, www, app, admin, status, …) are never given out as auto subdomains (<slug>-<id> instead). 7 tests. npm run cert:install-wildcard -- <cert.pem> <key.pem> installs the certificate on every runner over Pushify's own SSH connection, after checking the key matches and the certificate covers *.PREVIEW_BASE_URL.
  • Shared runner: marketplace apps with their own database are isolated too. They (and compose stacks) run on networks of their own (br-*), which the rules didn't cover. On a runner those networks now get the same limits — no host services beyond 80/443, no private / link-local / CGNAT ranges — while traffic inside one network (the app and its database) stays allowed; Docker already drops traffic between networks. Skipped, with a deploy-log warning, when a container that isn't Pushify's (by name or compose project) is on them. E2E: an app on such a network reaches its database and the internet, not a host service or another network; with the rules off the same test fails.
  • Shared runner: builds are isolated too. docker build runs its steps on Docker's default bridge, which the rules didn't cover, so a build could reach the host's services and anything else on that bridge. On a runner the default bridge now gets the same rules (no host services beyond 80/443, no neighbours, no private ranges; the internet stays reachable) — unless a container that isn't Pushify's is attached to it, in which case it is left alone and the deploy log says so. E2E: a build step probes a service on the host and a container on the default bridge (closed) and the internet (open); with the rules off the same test fails.
  • Plain Node.js apps run their `build` script. Without a configured build command the generated Dockerfile ran no build at all, so e.g. a TypeScript API started from a dist/ that was never compiled. It now runs npm run build --if-present, like Heroku and Railway; a configured build command still replaces it.
  • Shared runner: network isolation between apps. On a shared runner, apps and their workers now run on a Docker network of their own, pushify-apps (bridge pushify-apps0, inter-container traffic off), and every deploy (re)applies firewall rules that match only that bridge (lib/runner-isolation.ts, installed as /opt/pushify/runner-isolation.sh plus a boot unit where systemd runs; chains PUSHIFY-FWD from DOCKER-USER and PUSHIFY-IN from INPUT, so nothing else on the host is touched): an app can no longer open connections to another app, to the host's own services (only its nginx on 80/443), to private / link-local / CGNAT ranges (cloud metadata, private networks, other Docker networks), and nothing from outside reaches an app container except through nginx. App containers there are published on 127.0.0.1 only. An app without a domain (no custom domain, and no wildcard certificate for an auto subdomain on that runner) is reached at <server-ip>:<port>: that port is now nginx's (workers/public-port-proxy.ts, site pushify-<slug>.port, registry key <slug>:public), forwarded to whichever container is current. Internet egress and replies are untouched. Apps already running on a runner move over on their next deploy. E2E: two apps on a runner — each still served over HTTPS; one can't reach the other's container or host port, or the host's sshd, and can reach the host's 443 and the internet; with the rules turned off the same test fails.
  • Control-plane backups tell you when they fail. scripts/backup-control-plane.sh now also checks the dump's gzip integrity and pg_dump's completion marker, fails (instead of warning) when BACKUP_RCLONE_REMOTE is set but rclone is missing, lists the upload back from the remote, prunes remote copies older than BACKUP_REMOTE_KEEP_DAYS (60), and pings BACKUP_HEARTBEAT_URL on success / <url>/fail on failure. docs/SELF_HOSTING.md walks through an encrypted off-site copy to a Hetzner Storage Box (rclone sftp + crypt) and restoring from it.
  • E2E: managed databases. Postgres through the real service on the e2e server: a Node app connected to it reads and writes through the injected DATABASE_URL, a backup restores exactly (rows deleted and added afterwards are gone), the same backup restores a second time, and after a password reset the database takes the new password and the redeployed app works. Redis on every run; MySQL and MongoDB with PUSHIFY_E2E_DB_ENGINES=redis,mysql,mongodb: backup + restore, and a reset password that still works after a container restart. Every managed-database fix below was found or confirmed by it.
  • Database credentials include the internal address (internalConnectionString, internalHost, internalPort) — what apps on the same server use.
  • E2E: pushify.yaml app lifecycle. A Node app declaring a volume, a worker and a cron job in pushify.yaml: the request counter kept on the volume survives a redeploy (v2) and a rollback (back to v1), the worker container runs, the cron job exists, and a domain added after the deploy is verified and served over HTTPS without a redeploy.
  • End-to-end deploy tests (`npm run test:e2e`, CI on every push/PR). Fixture repos go through the real worker to a real server — a privileged container that is the customer's VPS (sshd as root, Docker, nginx, certbot → Pebble) plus Postgres for the control plane — and are checked over HTTP(S) like a visitor would: a static site whose nginx.conf points at a subfolder (served over HTTPS on apex + www, .git / Dockerfile / nginx.conf 404, HTTP → HTTPS) and a Node app (buildpack build, blue-green redeploy v1 → v2, one container left). The checkout is mounted read-only. Its first runs found the four fixes below.
  • `GET /projects/:id/git-access` — which credential Pushify would use to read the project's repository, never the token: status (ok · viewer_only · public · no_access · unknown · not_git), the account it goes through (via), the caller's own GitHub connection (connected, username, hasRepoScope) and whether the App / OAuth are configured. status describes push deploys (App or org owner only); viewer_only means just the caller's account can read it. Drives the dashboard's new Settings → GitHub access card. 7 tests.
  • Deploy failure category `repository_access` for clones that fail on credentials (could not read Username, Repository not found, Authentication failed, and the new pre-clone message), checked before the others so "killed" in a clone log is no longer read as out-of-memory.
  • Admin panel API for platform operators. GET /api/v1/admin/overview (user counts, the sign-up → verified → project → deploy → live → server → paid funnel, 30-day sign-ups/sign-ins, deployments by status, resources, plans), GET /admin/users (per-user summary: plan, projects, deploys, failed deploys, servers, databases, last sign-in, last seen; search + sort + paging), GET /admin/users/:id (profile, organisations, projects, deployments, servers, databases, active sessions, sign-in history, activity, and a merged timeline), GET /admin/activity and GET /admin/auth-events (platform-wide, paged). Plain Hono, deliberately absent from the Swagger document.
  • The gate. requirePlatformAdmin sits on the whole /admin router: the caller must be a session (never an API key) whose email is in the new ADMIN_EMAILS env var *and* have 2FA enabled. Non-operators get 404, the same as an unknown URL, so the panel's existence is not confirmed; an operator without 2FA gets a 403 that says what to fix. The allowlist lives in the environment, so nothing reachable through the app can promote anyone; the check re-reads the user row on every request, so removals apply immediately. Every request that passes is written to admin_audit_logs. GET /auth/me now reports isPlatformAdmin so the dashboard can show the link. 29 tests walk the router's own route table and assert each endpoint answers 401 / 404 / 404 / 403 / 200 for no session / API key / non-operator / operator without 2FA / operator — a route added later is covered automatically.
  • Sign-in history (auth_events, migration 0045). Sessions are deleted on logout and expiry, so until now there was no record that a login ever happened. Every attempt is recorded — register, login, login_failed, two_factor_required, two_factor_failed — with method (password / two_factor / github / google), IP and user agent, from the password, 2FA-completion and both OAuth flows. Writes are fire-and-forget and can never fail a login. Registration and 2FA completion now also store IP/user-agent on the session they create.
  • Why deploys fail. GET /admin/overview now returns failures: the last 30 days of failed deployments grouped by the existing classifier's category (lib/deploy-failure-summary.ts), with count, distinct projects, blame (Pushify / server / project) and the latest raw message per bucket — the histogram that says which error to fix in the product rather than in one user's project. 6 tests.
  • Stuck-user filters. GET /admin/users?filter=unverified|no_project|failing — never verified, never created a project, or every deploy so far failed.
  • `npm run stripe:audit` — read-only look at the Stripe account from wherever the key lives: active recurring prices (is there a $15 Hobby / $29 Pro price at all?), what the last subscription checkouts actually charged, every incomplete / incomplete_expired / past_due subscription with its first invoice's payment error (decline code, 3DS next action), and the webhook endpoints. Creates and changes nothing.

Fixed

  • Builds stop re-downloading the same dependencies every time. Python had a BuildKit cache mount for pip and then passed --no-cache-dir, which tells pip to use no cache at all — so the mount did nothing and every deploy pulled every wheel from PyPI again. (The flag is normally there to keep the image small; a cache *mount* never reaches the image, so there was nothing to save.) Ruby and PHP had no cache at all: every build re-downloaded every gem and every Composer package. Bundler's download cache (/usr/local/bundle/cache — only the .gem files, so installed gems still ship in the image) and Composer's (/root/.composer/cache, with COMPOSER_HOME pinned so the path is the one mounted) are now cached like Node's npm/yarn/pnpm caches already were. A project's own install command is left exactly as written.
  • A CSV export could run as a formula when it was opened. Excel and Google Sheets read a field starting with =, +, - or @ as a formula, and the database browser's "export as CSV" wrote table contents straight out — so a value someone had stored could execute on the machine of whoever opened the file. Such fields are now prefixed with a quote, which keeps them readable and inert. The new audit-log export uses the same writer.
  • A container on a shared runner can no longer be handed the host. Nothing checked what a container mounted, so a stack could take /var/run/docker.sock — the Docker API, which is root on the box and walks straight past the network isolation — or privileged, network_mode: host, pid: host, cap_add, devices or a security_opt that switches a sandbox off. Reachable in two steps today: install a template that mounts the socket (Portainer, Supabase's log collector) on a server of your own, then clear the project's server assignment, and the redeploy lands on the shared runner with the mount. lib/host-access-guard.ts now checks every volume and every compose service before anything is written to the server: on a shared runner a container may mount its own project directory and nothing else of the host, including through a named volume with driver_opts: device: — and :ro counts for nothing, since it makes the socket file read-only, not the API behind it. The refusal names the service and says the app belongs on a server of your own. On the customer's own server nothing changes: it is their host. 12 tests.
  • Deploy logs no longer show a registry permission error on every build. Builds passed --cache-from pushify-<slug>:latest, a tag that exists only on the server. BuildKit — which every build uses — reads --cache-from as a *registry* reference, so it looked the tag up on Docker Hub, was refused, and printed failed to configure registry cache importer: pull access denied into the customer's build log. It never cached anything either. Removed, along with the two docker image inspect calls that fed it; layer caching comes from BuildKit's own store on the server and is unchanged (91 cached steps across an e2e run, with and without it).
  • A deploy no longer hangs when Redis is configured but unreachable. Queueing the job waited forever, with the API request behind it; it now gives up after 5 seconds and leaves the deployment to the worker that polls the database anyway.
  • Blue-green no longer mistakes the app's image for a legacy container. The check for a pre-blue-green pushify-<slug> container was a bare docker inspect, which also matches the image of that name, so every first deploy logged "Migrating from legacy single-container" and "retired" a container that didn't exist. It now inspects containers only.
  • Shared runner: an unknown host name got another customer's site. nginx answers a TLS request for a name it has no site for with the first TLS site it loaded — on the runner, a customer's site and certificate. Runner deploys now add a catch-all default_server on 443 (self-signed, closes the connection), unless one exists; Debian's commented-out # listen 443 ssl default_server; no longer counts as one. Covered in the e2e runner case.
  • An app without a domain kept its URL for one deploy only. The zero-downtime switch starts every deploy's container on a new host port and keeps it, so a domain-less app's http://<server-ip>:<port> changed with each deploy (3005 → 3006 → 3005 …) and every shared link broke. That port now belongs to nginx and stays put — on shared runners and on customers' own servers alike (where nginx runs, loads sites-enabled and SELinux isn't enforcing; otherwise the container publishes its port as before); the first deploy after this takes over the port the app is reachable on today (one moment of downtime while it moves from the container to nginx), so existing links keep working. It goes away when the app is served under a domain, and with the project. nginx is checked to answer on the port before the deploy counts as done. E2E: redeploys keep the URL (runner and own server), and an app deployed the old way keeps its port.
  • Connecting a database to a project did nothing. The connection was stored and never read: no deploy injected anything, and the only address Pushify showed (server IP + host port) is bound to 127.0.0.1, unreachable from app containers. Deploys now set each connected database as an env var (DATABASE_URL unless the connection names another) with the container address when it is on the same server (postgresql://…@pushify-db-<name>:5432/…), or its public address when it is on another server with external access. A variable the project sets itself wins; the deploy log says what was set, and why not when it wasn't.
  • Apps couldn't reach Pushify databases by name. The deploy joined pushify-<slug> to the pushify network after start, but blue-green containers are pushify-<slug>-blue|green, so the join always failed silently. Apps, workers and rollbacks now start on the network when the server has Pushify databases (an app that connects or migrates at boot no longer races the join) — never on the shared runner. New databases start on it; existing ones are joined at the next deploy.
  • Database backups restore. Postgres restores replayed the dump into the live schema without stopping on errors: existing tables collided and rows deleted since the backup stayed deleted. Dumps now use --clean --if-exists; restores reset the public schema and run in one transaction with ON_ERROR_STOP — all or nothing, older dumps included. A restored backup was left restored, which hid its restore / download buttons and took it out of verification for good; it stays completed (existing rows count as completed). Redis restores used SHUTDOWN NOSAVE, which the restart policy undid on the old data, and Redis backups copied a BGSAVE that might not have finished. A failing dump tool now fails the backup instead of saving an empty archive (pipefail), MySQL dumps no longer need the PROCESS privilege (--no-tablespaces, --single-transaction), and temporary dump files are removed from the container.
  • Database password reset. Postgres connected to a database named after the user (which doesn't exist), MongoDB ran without authenticating, Redis ran CONFIG SET without auth and a restart would undo it anyway — and no result was checked, so the new password was stored even when the database never got it, locking backups and the studio out. Each engine now really changes it (MySQL's root too; Redis is recreated with it) and a failure returns an error and stores nothing.
  • Re-creating a deleted database under the same name reused its old data directory, so the image kept the old credentials and the new ones never worked. The old directory is moved to /opt/pushify/databases/.old/ first.
  • Database input validation. Create checks name, type and version (an image tag) and refuses a second name that maps to the same container (My DB / my_db); connect checks the variable name. Database containers get the same log cap, no-new-privileges and NET_RAW drop as app containers. MongoDB connection strings carry authSource=admin, where the image creates the user.
  • The database page no longer returns connected projects' full rows (including their webhook secret) — only id, name, slug and server.
  • Rollback works after blue-green deploys. Quick rollback started the old image as pushify-<slug> on the project's port — the port the active blue/green slot holds — so every rollback after a normal deploy failed with "port is already allocated", and it never re-pointed nginx anyway. It now uses the same switch as a deploy: the rollback image starts in the other slot, is health-checked, the project's domains are re-pointed (certificates kept), then the current slot is retired. Found by the new e2e case below.
  • Security: no customer deploys on the control plane in production. A project with no server and no configured runner fell back to building and running on the control plane's own Docker — harmless while that host had no Docker, not anymore. In production this now fails with "assign a server" unless PUSHIFY_ALLOW_LOCAL_DEPLOYS=true (still allowed by default in development). Documented in .env.example with PUSHIFY_RUNNER_SERVER_IDS.
  • Security: app containers are hardened for shared hosts. Every customer app, worker and marketplace container now starts with --cap-drop NET_RAW (no raw sockets / ARP spoofing on the shared Docker bridge), --security-opt no-new-privileges, and a capped json-file log (10 MB × 3 — the default was unbounded, so one app could fill the host's disk). Asserted in the e2e suite. Managed database containers get them when created or reconfigured (below).
  • Security: stricter validation of repository settings. Repository URL (https/http only), branch name, root directory, Dockerfile path, output directory and build / install / start commands are validated when a project is created or updated, in pushify.yaml, and again right before each deploy; the git commands that use them are fully quoted. Local file:// repositories are refused (PUSHIFY_ALLOW_LOCAL_REPOS=1 enables them, for the e2e box only). Covered by unit tests and an e2e case.
  • "Deployed" now means the new config is serving, and blue-green no longer 502s the tail. nginx -s reload returns before the old workers stop accepting connections; the deploy then retired the old container ~50 ms later, so requests accepted by old workers could hit a removed container. Reloads now wait (≤5 s) until the host master's previous workers have exited or are shutting down — app containers' own nginx workers are ignored — so the deploy only reports success once the new vhost answers. Same wait on the static-site path.
  • Nginx reload/restart work without systemd. reloadNginx ran systemctl reload nginx only, so on LXC / container / OpenRC servers every domain step failed; it falls back to nginx -s reload (restart to quit + start).
  • Health checks no longer need `nc`. The blue-green TCP check was nc -z, which Pushify's server setup never installs and Debian/RHEL images lack — every deploy there ended in "health check timeout" while the app ran. It falls back to bash's /dev/tcp, then curl (connected-but-not-HTTP counts). 2 tests.
  • Deploy failures are classified by their error first. The whole log was matched first, and every Node build log contains the generated RUN if [ -d node_modules/lightningcss ] … line, so any Node deploy that failed after its build was filed as "Native module (platform)", blamed on Pushify — in the admin failure histogram and the hint shown to users. 3 tests.
  • CI ran on nothing. The workflow triggered on main, but the default branch is master; it now runs on master, main and release/** pushes and PRs.
  • SECURITY: raw Nginx config from customers on the shared runner. A domain's custom location blocks are inserted verbatim into the host's Nginx, and custom header names/values were written unescaped (add_header K "V"), so any customer — free plan included — could add alias / proxy_pass directives to the shared host and read its files or reach other customers' apps. Custom location blocks are now refused on a shared runner (400, EN/TR message) and left out of the rendered vhost there even if already stored (the deploy log says so); they still work on the customer's own server. Header names must be [A-Za-z0-9-], values may not contain quotes, backslashes or control characters — checked at the API and again when rendering. 7 tests.
  • Clearing an Nginx setting actually clears it. PATCH …/nginx-settings merged with a plain spread, so nothing could ever be removed: emptied custom location blocks, a removed proxy port, deleted headers, disabled rate limiting or caching all stayed in the host vhost (www.yeliapp.com kept serving the host's own "Welcome to nginx!" through leftover location = / blocks). Fields sent as null, '' or {} — and features sent with enabled: false — are now removed; fields left out keep their value. The schema accepts null for those fields. 4 tests.
  • SECURITY: static sites served the repository's `.git` — with the clone token in `.git/config`. Repos are cloned with the access token in the URL, git stores that URL in .git/config, and the static buildpack copied the whole checkout into nginx's web root, so https://<site>/.git/config handed out the GitHub token (a long-lived OAuth token when the deploy used a connected account), along with the Dockerfile and anything else in the repo. Both clone paths now reset origin to the clean URL right after cloning (the deploy fails rather than keep a token it can't remove), and the static image no longer contains the checkout at all (below). Every static site deployed before this must be redeployed, and OAuth tokens that may have been exposed revoked.
  • Static sites: the right folder, never "Welcome to nginx!". The static Dockerfile is now two stages. The first picks the web root — the folder the repo's nginx.conf root names (matched by its tail, so /usr/share/nginx/html/site or /var/www/site finds ./site), else the first of ., public, dist, build, site, www, html, docs, src, web, static, app with an index page, else wherever the .html files are — copies only that, minus .git, .github, Dockerfile, compose files, .env*, pushify.yaml, node_modules and the nginx config itself, and makes sure there is an index.html (Index.html, index.htm, or a page named home / main / anasayfa …). The final image starts from an empty web root, so a site that isn't found shows a 404, not nginx's welcome page. The default config serves clean URLs (/about → about.html), real 404s when the site has a 404.html (else the index fallback), 1-hour caching for assets (not a year: hand-written CSS isn't content-hashed) and never serves dotfiles.
  • Static sites: a repo `nginx.conf` written for a VPS works. It is still used, but made to fit behind Pushify: listen moves to the app port over plain HTTP, ssl_* / http2 lines go, root points at the web root, and server blocks that redirect to https:// are dropped (behind the proxy they loop). A full config (http { }) is not used, and a config that fails nginx -t falls back to the default instead of breaking the deploy — the build log says which happened. Also looked for in nginx/, .nginx/ and conf/.
  • Static detection finds Index.html, index.htm and sites in a subfolder (any page within two levels), locally and on the server; it only wins when no language is detected. 13 tests run the script for real; verified end to end with nginx:alpine on five repo layouts.
  • A project's domains no longer overwrite each other in Nginx. Every writer — domain verify, every deploy, the auto subdomain, Nginx settings — wrote *its one domain* into the project's single vhost file (pushify-<slug>), so verifying www.example.com dropped example.com's block and certificate (and vice versa), every deploy dropped every domain but the primary, and a deploy wiped custom Nginx settings. Deleting any domain removed the whole file, taking the others offline. All of them now go through syncProjectSites, which renders every domain of the project from the database into that file (auto subdomain on the wildcard cert, each custom domain with its own certificate, per-domain settings and rate-limit zones), and on a failed nginx -t puts the previous file back instead of deleting it.
  • example.com and www.example.com both work. A custom domain's www / apex twin is served as a 301 to it — and put on the same certificate — whenever the twin's DNS points at the server and it isn't a domain of its own. Certificates use --cert-name <domain> --expand, so adding www later extends the existing certificate instead of landing in <domain>-0001; if the www challenge fails the main domain still gets its certificate. Certbot only runs for what is missing (deploys no longer request a certificate every time), only for names whose DNS already points here, and falls back from the nginx plugin to a webroot every port-80 block now serves (/.well-known/acme-challenge/) instead of standalone, which can't bind port 80 next to Nginx.
  • Custom domains on the shared runner. Verify, DNS setup, delete and Nginx settings used project.serverId only, so projects deployed to the shared runner (free / no server) could not verify a domain at all; they now resolve the runner like deploys do.
  • 14 tests.
  • "Reconnect GitHub" can switch to another GitHub account. GitHub silently re-authorizes whichever account is signed in on github.com, skipping the consent screen when the scopes were granted before — so reconnecting always came back with the same account. The connect flow now sends prompt=select_account and GitHub shows its account picker; sign-in with GitHub is unchanged. 3 tests.
  • Connecting a GitHub account works for private repositories again. Since the GitHub App landed (2026-08-21) the OAuth flow asked only for identity whenever an App was configured, so every account connected or reconnected after that could not clone its private repos — connecting GitHub without the App, which used to be enough, silently stopped working. It always asks for repo again. Accounts connected in that window still report hasRepoScope: false; the dashboard asks them to reconnect.
  • Deploys use a credential that can actually read the repository. Order: a GitHub App installation on the repo owner that covers the repo → the OAuth connection of the member who started the deploy → the organisation owner's. Each OAuth token is checked with GET /repos/{owner}/{repo} before it is used, so a connection to a different GitHub account (someone switched accounts, or installed the App on another one) is skipped instead of cloning with the wrong token; a GitHub that doesn't answer never counts as "no". Other members' connections are deliberately never used. A private repo nothing connected can read now fails before cloning, naming the repo and the account to install the App on, instead of git's could not read Username for 'https://github.com'; public repos still clone without credentials. The deploy log says which credential was used. Webhook install and preview comments go through the same resolver.
  • Private GitHub repos failed to clone after the GitHub App landed (could not read Username for 'https://github.com'). getProjectGitAccessToken tried the App installation and the owner's OAuth token inside one try, so any App failure — a token that couldn't be minted, a removed installation, GitHub not answering — returned no credential at all instead of falling back to the OAuth token that had always worked, and the clone ran anonymously. The App path now has its own try, logs why it failed and falls through to OAuth; a deploy that ends up with no credential says so in its log. The test that asserted the old null has been replaced by two fallback tests.
  • GitHub App webhooks never reached their handler. POST /api/v1/webhooks/github/app was mounted *after* the per-project POST /webhooks/github/:projectId; Hono runs matching handlers in registration order, and the per-project route's uuid validator answered 400 for the literal app before the App handler could run — so every installation-sync and push/PR delivery from the GitHub App has failed since the App shipped (verified with app.request(): 400 ZodError before, the App handler after). The App router is now mounted first.
  • Deployment log history was wiped every minute. The log collector's retention pass called and() with no conditions; drizzle returns undefined for that, and .where(undefined) is no WHERE at all — so the cleanup ran DELETE FROM container_logs on every 60-second collection cycle since the collector shipped. That is why the Deployments → History view was always empty while live streaming worked. Now deletes only rows older than the 7-day retention window, once an hour (lib/… generated SQL checked: where created_at < $1).
  • A PR preview deploy could take over the production domain. The remote deploy path resolved the project's primary domain and rewrote its nginx vhost regardless of whether the deploy was a preview, so opening a PR on a project with a custom domain pointed that domain at the PR container's port; a project without a domain got its production auto-subdomain created for the preview. Preview deploys now leave the project's domains alone and stay reachable at http://<server-ip>:<port> (the pr-N-<slug>.<preview base> URL posted to the PR still has no vhost of its own — that is a separate, pre-existing gap).
  • Plan quotas now apply to git-push deploys. The dashboard deploy path checked the deploys/month, build-minute, storage and bandwidth quotas and the billing hold; the GitHub and GitLab push paths created the deployment directly, so the caps on the pricing page never applied to the product's main flow. Both push paths now run the same gate (lib/push-deploy-gate.ts); a refusal is recorded as a failed deployment carrying the reason, logged to the activity feed and sent to the project's notification channels, so the person can see why the push did nothing. 6 tests.
  • Storage metering measured nothing for server deploys, and deleted projects left their images behind. The remote deployer names images pushify-<slug> but the storage meter and the delete-time cleanup only looked for pushify/<slug> (the local-host form) — so every server-deployed project reported 0 GB and its images stayed on the server after deletion. Both now recognise both forms (lib/project-image-names.ts) and match the image plus its -pr-N preview builds only, never a sibling project that shares the slug as a prefix. 3 tests.
  • Container metrics are pruned again. cleanOldMetrics existed but nothing called it, so container_metrics grew without bound and monitoring got slower on long-lived installs. The metrics worker now prunes rows older than 7 days once an hour.
  • Environment variables are scoped to the environment they were saved under. The environment column (production / staging / development / preview) was set by the API and shown in the dashboard but never read at deploy time — every deploy received every row, so staging and development values reached production containers and PR previews got the production secrets. A production deploy now injects production rows only; a preview deploy injects production with preview rows layered on top (so a preview works out of the box and a preview value overrides its production twin). Staging and development rows are never injected. lib/deploy-env-vars.ts, 4 tests.
  • "Deployment alerts" in Settings → Notifications now does something. The preference was saved and never read. Every member who keeps it on is emailed when a production deployment fails, and once more when the next one succeeds after a failure; successes on their own are not mailed (per-project channels cover those) and previews never are. Rides on the same deployment.failed / deployment.success events as channels (services/deployment-alert.service.ts, 5 tests). The "Product updates" preference, which nothing sent, is dropped from the API.
  • Deploys are zero-downtime now, as the pricing page said. The blue-green switch used to stop the old container, then stop and re-create the health-checked new one on the production port — a full cold start of downtime on every deploy, with the surviving container started under different memory limits than the one that passed the check. Now the new container gets its own host port from the port registry, is health-checked there, nginx is re-pointed at it (a reload, no dropped requests) and only then is the old container retired and its firewall port closed. Consequence: a project's host port changes between deploys (the registry follows it); the dashboard and nginx always show the current one. Setting PORT no longer forces the host port, which also removes the "port is already allocated" collision between two projects on one server. 4 tests on the port picker.
  • Framework detection and `pushify.yaml` commands are honoured on server deploys. Three things went wrong together: (1) the remote detector returned on the first file it recognised, and it read package.json first — so any Laravel, Rails or Django app that ships a package.json for its front-end was built as a Node app; now every language is checked and the most confident match wins, exactly like the local detector. (2) framework: in pushify.yaml was only a fallback for when detection found nothing; it now pins the buildpack (📌 Framework set by pushify.yaml), and a name no buildpack knows falls back to detection with a warning. (3) Every framework path except the generic ones ignored the install/build/start commands set in the dashboard or the config file — Next.js and Nuxt always ran npm run build / npm start, Django always started config.wsgi (which only exists for projects literally named config) and only copied requirements.txt (Poetry and Pipenv projects failed to build), Laravel, Rails, Go, Rust and Java each hard-coded their own. All of them now use the configured commands; start commands run through sh -c so flags, env vars and && work; Django finds its wsgi module at start-up; Python installs from whichever of requirements.txt, Pipfile or pyproject.toml exists; SvelteKit's static output defaults to build/. 25 tests.
  • PR previews on a server get their own URL, and go away when the PR closes. The https://pr-N-<slug>.<preview base> link posted to every pull request had no nginx vhost behind it on server deploys — it resolved to the shared host and answered with whatever site nginx picked first. The deployer now writes a pushify-<slug>-pr-N vhost on the wildcard certificate (same mechanism as the production auto-subdomain), so the link works; on a server without that certificate the PR comment carries the reachable http://<ip>:<port> address instead of a dead hostname. Closing the PR used to run docker rm on the API host only, so on a server the preview container kept running (and billing CPU/RAM) forever: cleanup now goes over SSH to the project's server and removes the container, its image, the vhost, the port-registry entry and the firewall rule; the local path also drops the conf.d/preview-… file it wrote. Deleting a project now also removes the pushify-<slug> and pushify-<slug>-pr-* vhosts it left in nginx. 8 tests.
  • Misc. /health really pings Redis instead of parsing the URL; the GitHub App "Install" button reports configured: false when GITHUB_APP_SLUG is missing instead of doing nothing; RATE_LIMIT_ENABLED=false actually disables rate limiting (z.coerce.boolean() treated the string "false" as true); HETZNER_API_TOKEN is part of the validated env; the BullMQ server workers read REDIS_URL from the validated env instead of silently retrying localhost:6379; Bitbucket, which never had an integration, is no longer accepted as a git provider.

v0.2.0-beta.66

Dashboard

Added

  • Data Browser for PostgreSQL/MySQL databases (/dashboard/databases/[id]/studio, linked from the database detail header while the database is running). A table list with search, row estimates and size; a data grid with column sorting, paging and per-column filters (equals, contains, starts/ends with, comparisons, is null); add / edit / delete rows through a type-aware editor that respects nullability, defaults and binary columns; and a SQL console that is read-only by default, with write mode behind an explicit confirmation. Views and primary-key-less tables are clearly marked read-only. EN/TR i18n.
  • Install the Pushify GitHub App from the new-project screen when the platform has one configured — offered above the OAuth connect, with the plain reason next to it. GitHub sends the browser to /auth/github/app-setup, which links the installation to the organisation and hands the person back to what they were doing.
  • Per-member data-browser permissions. The team page gains a data-access selector for members and viewers — no access (the default), read, or edit — beside the existing project-access chip. A read-only user gets the same browser without the actions that would fail: no new table, no add/edit/delete row, no CSV import, no row selection.
  • Cancel a running query from the Performance tab's running-queries list.
  • MongoDB and Redis get their own browsers. Mongo: collection list with document counts, a paged document view with JSON filter and sort inputs, a JSON editor for adding and editing documents, multi-select delete, create/drop collection. Redis: pattern search over a cursor-paged SCAN, type badges and TTL per key, a type-aware value view (string editor, list items, hash/zset field tables), TTL editing and bulk delete. The Data Browser link now appears for all four engines.
  • Index management and a Performance tab. The structure panel lists a table's indexes with their columns, uniqueness and size, and creates or drops them (click columns in order to compose a composite index). The new Performance tab shows the slowest statements and what is running right now, and a slow query opens straight into the SQL console.
  • CSV import. Pick a file, confirm the header and delimiter (auto-detected), map each CSV column to a table column, preview the first rows, then import in batches with a progress bar. Empty cells become NULL by default. The CSV reader is RFC 4180 (quotes, embedded commas and newlines, CRLF, BOM) and ships with the frontend's first unit test suite — npm test, 11 tests.
  • A professional SQL console. CodeMirror 6 editor with SQL syntax highlighting, line numbers, bracket matching and autocomplete fed by the live schema (tables, qualified names, columns) — the editor is lazy-loaded and themed from the dashboard's own CSS variables, so it follows light/dark. Around it: a schema explorer that inserts names at the cursor, ⌘↵ to run, run just the selected text, an EXPLAIN button, one-click SQL formatting, tabbed Result / Messages / History panes, query history kept per database (timing, row count, click to reload), a full-value cell viewer with JSON pretty-printing, and CSV/JSON export of the complete result — not just the page on screen. The same cell viewer now opens from the data grid.
  • Data Browser feels interactive now. Row counts above 10k are shown as estimates (~1.2M rows (estimated)) instead of blocking the page on a scan, paging stays enabled while pages come back full, and a revisited table is served from cache for 10s rather than re-crossing the network.
  • Schema editing in the Data Browser. "New table" opens a column builder (name, type from the engine's own type list, length/scale, nullable, unique, primary key, auto-increment, default) and a Structure panel per table adds or drops columns, renames the table, and holds a danger zone for emptying or deleting it — each destructive step behind its own confirmation.

v0.2.0-beta.62

Platform & API

Added

  • Database Studio — a data browser for managed PostgreSQL/MySQL databases. New endpoints under /databases/:id/studio: GET /tables (tables and views with row estimates, size and primary-key info), GET /rows (paged, sortable, filterable rows for one table), POST/PATCH /rows and POST /rows/delete (row insert / update / bulk delete, always addressed by primary key), and POST /query (SQL console). Queries reach the database the same way the rest of the database service does — SSH to the server, then docker exec the engine's own client — so nothing has to be exposed to the network. Owner/admin only; API keys need databases:read, and databases:write for writes.
  • Schema editing from the studio. POST /studio/tables (create), DELETE /studio/tables (drop table or view), POST /studio/tables/truncate, POST /studio/tables/rename, POST /studio/columns (add) and DELETE /studio/columns (drop). Column types cannot be catalog-checked the way names can — they do not exist yet — so every type token comes from a per-engine allowlist and only the matched token is emitted; lengths and scales must be integers in range; defaults are quoted literals unless they match a short allowlist of expressions (now(), CURRENT_TIMESTAMP, gen_random_uuid(), uuid(), …). Auto-increment maps to serial/bigserial on Postgres and to AUTO_INCREMENT (primary key required) on MySQL. Every schema change is logged as database.schema_changed (migration 0042). 19 unit tests on the DDL renderer.
  • GitHub App: repository access that outlives the person who set it up. Every deploy used to borrow the organisation *owner's* personal OAuth token, with the repo scope over every repository that person could reach — so the owner leaving, revoking the grant or rotating the token stopped every project in the organisation. Now an installation (github_app_installations, migration 0044) is the credential: it belongs to the GitHub account, covers only the repositories that account picked, and is never stored as a long-lived token — lib/github-app-auth.ts signs a short app JWT and mints one-hour installation tokens on demand, cached with a five-minute refresh margin (16 tests, including the PKCS#1 key GitHub hands out and the escaped newlines a .env produces). New endpoints: GET /integrations/github/app/install-url, POST /integrations/github/app/setup, GET /integrations/github/app/installations[/:id/repositories], and one webhook at POST /webhooks/github/app that keeps installations in sync and fans push/pull_request events out to every project tracking that repository.
  • Both paths run side by side. The token resolver prefers an installation and falls back to the owner's OAuth token, so projects connected before the App keep deploying untouched. The App path is tried *before* the owner lookup, which is what makes it survive the case OAuth cannot. A repository the App now covers makes the legacy per-repo webhook stand down, so a migrated project cannot deploy twice from one push. 10 tests on the resolver.
  • The OAuth app is identity-only once an App is configured — the requested scope drops from repo read:user user:email to read:user user:email. Deployments without an App keep the old scope, so nothing breaks for them.
  • Push and pull-request handling extracted to lib/github-deploy-trigger.ts so the per-project route and the App endpoint run the same code instead of two copies that drift.
  • Data-browser permissions. organization_members.studio_access (none | read | write, migration 0043) with PUT /organizations/members/:userId/studio-access. Owners and admins keep full access by virtue of their role; everyone else defaults to none, so nothing changes for an organisation that does not opt in. A read grant browses tables, rows, indexes, diagnostics and read-only console queries; every write — row edits, DDL, imports, write-mode queries, cancels — needs a write grant. listTables reports the caller's level so the UI can hide what would 403. 26 access-control tests with the repositories and SSH mocked (role gate, cross-organisation 404, engine gate, container state, read-vs-write split) plus 6 on the API-key scope middleware.
  • Query cancellation. POST /studio/cancel stops a running statement — pg_cancel_backend scoped to the current database, or an ownership check followed by KILL QUERY on MySQL. Cancelling something that already finished reports that, rather than failing.
  • SSH connection pooling rolled out. The pool had existed for a long time but only the studio used it; 12 short, frequent operations across metrics, log collection, app-sleep, usage metering, health scans, container resolution and remote cleanup now reuse a pooled connection instead of paying a fresh handshake (~250-500ms) every time. Deployments, backups, server provisioning, certbot and user cron keep their own connection: they run long or stream for minutes, and OpenSSH caps concurrent channels per connection. A pooled client now ignores disconnect() from its callers — the pool's reaper owns the lifecycle — so a shared connection can never be closed out from under another caller.
  • `npm run lint` works again. The backend had ESLint 9 installed but no config file at all, so linting never ran. Added a flat config (typescript-eslint, non-type-checked so it stays fast) and cleared what it found: 33 pieces of dead code removed (unused imports, unused bindings whose calls were kept, two unreferenced private functions), a lexical declaration escaped from a case arm, catch {} on shutdown paths allowed, and control-character regexes permitted since sanitisation is exactly what they do. 69 errors → 0; the 28 remaining any uses are warnings so new ones still surface.
  • Studio engine layer extracted and tested against real databases. The SQL the studio generates, the command that carries it and the parsing of what comes back now live in lib/studio-sql.ts / lib/studio-nosql.ts — pure modules with no SSH, database or HTTP — leaving the services to own auth, sessions and orchestration (database-studio.service.ts 1635 → ~1150 lines, sessions shared via studio-session.service.ts). On top of that: 50 integration tests (npm run test:studio) that boot real PostgreSQL 16, MySQL 8.0, MongoDB 7 and Redis 7 containers and run the exact command production sends, with only the SSH hop replaced. They already earned their keep — they caught mongosh returning a REPL prompt instead of script output (every Mongo call would have failed in production) and an envelope parser that broke on any payload containing an ok field. Plus 60 new unit tests on the builders and parsers. Backend suite: 198 unit tests, 50 integration.
  • Index management and query diagnostics. GET/POST/DELETE /studio/indexes lists, creates and drops indexes (method from a per-engine allowlist, columns checked against the table, the primary key refused), and GET /studio/performance reports the slowest statements (pg_stat_statements / performance_schema) and what is running right now — an unavailable statistics source is reported to the UI, never thrown.
  • MongoDB and Redis studios. New /studio/mongo/* (collections, paged documents with a user-supplied filter and sort, insert/replace/delete by _id, create/drop collection) and /studio/redis/* (cursor SCAN with pattern, per-type value preview, TTL, string edit, bulk delete). Each engine gets its own injection defence: for Mongo every piece of user input enters the script as a JSON.stringify string literal parsed with EJSON.parse, so it is data and never code; for Redis the Lua program is fixed and every value is hex-encoded into ARGV and decoded inside Lua, which is also what makes binary keys and values survive the round trip.
  • CSV import. POST /studio/import appends a batch of rows (max 500 per request, client loops for progress) with every cell escaped exactly like a hand-edited value; empty cells become NULL unless the caller opts out.
  • SQL console became a real console. GET /studio/schema returns every table with its columns in one round trip (the editor's autocomplete source, capped at 500 tables); POST /studio/query takes a maxRows ceiling and now reports the statement's command and, where the engine tells us, the number of rows it affected (psql's command tag; ROW_COUNT() on MySQL); and POST /studio/query/export streams a query's full result as CSV or JSON, always read-only, up to 20k rows. CSV writing is RFC 4180 (5 unit tests).
  • Studio latency work — the transport was the cost, not the SQL. Every request used to pay a fresh SSH handshake (~250-500ms) plus a docker exec spawn (~150-400ms) for a query that runs in single-digit milliseconds, and reading rows paid the exec twice because the catalog lookup was its own round trip. Now: the studio uses the existing getSSHConnection pool instead of dialling a new connection each time (a pooled connection that died between requests is re-acquired *before* sending, never retried after, so a write can't apply twice); resolved table schemas are cached for 60s and invalidated by our own DDL, which takes paging/sorting/filtering down to one round trip; and unfiltered row counts stop at 10k and fall back to the planner's estimate (reltuples / TABLE_ROWS, flagged as totalEstimated) so a large table is never scanned to draw a page number.
  • Safety rails on the studio. The SQL script is base64'd onto the client's stdin (the shell never sees user input); identifiers are resolved against the live catalog before they can appear in a statement; literals are escaped with the session pinned to a known escaping mode (standard_conforming_strings on / NO_BACKSLASH_ESCAPES off). Tables without a primary key and views are read-only, binary columns are preview-only, and every read runs in a read-only transaction. The console is read-only unless the caller explicitly opts into write mode, in which case read mode accepts a single read statement only. Statement timeout 20s, output capped, row counts capped at 100k, console results capped at 500 rows. Row edits and every console query land in the activity log (database.data_modified, database.query_executed, migration 0041). 18 unit tests on the escaping and statement-classification rules.

v0.2.0-beta.60

Platform & API

Added

  • Config-as-code: `pushify.yaml`. A file at the repo root (or the project's root directory) now declares build & runtime settings and wins over dashboard values when present — the repo becomes the source of truth: build, install, start, output, port, framework, plus declared cron jobs (name/schedule/command/timezone, validated with the same cron/timezone rules as the UI) and volumes (name/path, same shell-safety validation). Cron and volume declarations sync on production deploys as upsert-only — removing an entry from the file never deletes data; the dashboard stays authoritative for removals. A malformed file is reported in the deploy log and ignored — it can never break a deploy. Applied on both the remote and local deploy paths; volume declarations take effect in the same deploy. 7 parser tests.

Changed

  • Install cache now covers yarn and pnpm too — the BuildKit cache mounts (npm + framework caches shipped earlier) gain yarn/pnpm store targets, so custom install commands hit a warm cache as well.

v0.2.0-beta.64

Dashboard

Changed

  • Notification preferences moved from localStorage to the account (pairs with backend beta.59). The Notifications tab now reads and saves through the API with optimistic toggles — settings finally follow you across browsers and devices, and the backend actually honors them (security alerts gate the new-sign-in email; Weekly Digest opts you into the new Monday summary). New "Getting-started emails" toggle controls the onboarding sequence from the same screen (same switch as the email unsubscribe link). EN/TR i18n.

v0.2.0-beta.63

Dashboard

Changed

  • Real company identity across the site. The global Organization schema now carries legalName: Pushify LLC and the registered US address (30 N Gould St Ste N, Sheridan, WY) as structured data on every page. The About page's company card shows the legal entity and address, and the footer copyright reads Pushify LLC. Closes the audit's gap of no legal entity anywhere on the site.

v0.2.0-beta.62

Dashboard

Fixed

  • Modals now render through the global portal. The cancellation dialog and the two domain dialogs (purchase, transfer-in) drew their own overlay inside the page tree, so an ancestor with a transform trapped the backdrop to one card instead of the whole screen. All three now use the shared portal-based Modal (renders to document.body, full-page blurred backdrop, ESC to close, scroll lock).

Added

  • In-app cancellation with a one-question exit survey (pairs with backend beta.58). Paid plans get a quiet "Cancel subscription" link under the Current Plan card; the dialog asks a single honest question (too expensive / missing features / bugs / switched / project ended / other + optional comment, "a human reads these"), records it best-effort, then cancels at period end with a clear "your data is not deleted" note. EN/TR i18n.

v0.2.0-beta.61

Dashboard

Added

  • Three SEO growth pages, built on verified data only (competitor facts checked against live public sources, July 2026 — no invented benchmarks, stars or testimonials; every page carries a "spot an error? email us" correction note): - `/vs/heroku` — the existing honest-comparison template applied to Heroku: real pricing (free tier removed Nov 2022; $5 Eco sleeps after 30 min; $7 Basic dyno + $5 smallest Postgres ≈ $12/mo minimum), give-them-their-due rows (zero-ops, 10+ year add-on ecosystem), migration FAQ, FAQPage schema. EN/TR. - `/alternatives` — the roundup-format hub the "coolify alternative / self-hosted heroku" SERPs actually reward: at-a-glance matrices (license, self-host, cloud, real starting prices, pricing model) for Coolify, Dokploy, CapRover, Dokku, Heroku, Railway, Render, plus honest per-tool reviews with "best for" verdicts — including Pushify's own weaknesses (younger ecosystem, smaller community) stated in its card. EN/TR, CollectionPage schema. - `/guides/deploy-nextjs` — a genuine step-by-step tutorial for the 100%-tutorial "deploy nextjs own server" SERP: Node 22 via NodeSource, swap for 1 GB builds, PM2 with systemd startup, full nginx reverse-proxy config, certbot SSL and a redeploy script with its downtime trade-off explained — then the automated Pushify route. Fully static SSR, TechArticle schema. - All three added to the sitemap and the footer's Resources column.

v0.2.0-beta.60

Dashboard

Changed

  • Ship only the active language — the Turkish dictionary is now a lazy chunk. Both full translation dictionaries (~4,400 lines each) were statically bundled into every page for every visitor. The bundle now contains only English; the Turkish dictionary loads on demand (once, then cached) when the locale is tr, with English fallback during the brief fetch and an automatic re-render when it lands. Homepage JS drops 1,518 → 1,408 KB raw and the TR chunk is no longer referenced by any page's initial load — the same saving applies to every route, including the dashboard. Verified on a production build: EN default renders English, a stored tr preference renders Turkish end-to-end.
  • Google Analytics moved fully off the critical path (lazyOnload instead of afterInteractive) — it no longer competes with hydration for main-thread time during the INP-sensitive window.

v0.2.0-beta.59

Dashboard

Changed

  • All settings tab contents brought into the quiet design language. Appearance: the theme picker is now three miniature dashboard previews rendered in each theme's own colors (System = half dark / half light) with a small check badge — the option shows itself instead of an icon; language options became calm pills. Sessions: session rows match the settings row pattern (small icon plate, wrapped meta line), "This device" is a green chip instead of an accent-tinted card, and the "you're only signed in here" copy no longer shows above a list of other sessions. API Keys: rows get the same treatment plus a green Active chip, the duplicated card header was deduped ("Your keys"), and meta wraps properly on mobile.
  • Settings navigation redesigned. The tab rail is now grouped the way the content actually splits — Account (Profile, Appearance, Notifications) and Access & security (Security, Sessions, API Keys) with quiet uppercase eyebrows. The loud accent-tinted active state and rotating chevrons are gone: active is a calm neutral pill with the icon as the only accent. On mobile the rail becomes a horizontally scrollable chip strip that auto-centers the active tab (deep links like ?tab=security land correctly). Verified in dark + light, desktop + 390px mobile.
  • Security settings visual refresh. The 2FA card grew a proper status header (shield icon plate — green when protected, muted when off) with plain-language state copy, and when 2FA is off, a quiet checklist of what enabling gets you (any authenticator app, 10 backup codes, every-device protection); when on, the footer hints how backup-code regeneration behaves. New Sign-in method card below shows how the account authenticates — password accounts see "Password is set · Active", Google/GitHub accounts see their social sign-in row plus a one-click Set a password shortcut to the Profile tab. Verified in both dark and Clean Pro light themes. EN/TR i18n.

v0.2.0-beta.58

Dashboard

Fixed

  • Settings adapt to Google/GitHub accounts without a password (pairs with backend beta.57). The 2FA disable and backup-code-regenerate dialogs now ask for a 6-digit authenticator code (or backup code) instead of a password when the account has none, with an explanatory hint. The Profile tab's password card becomes "Set password" for these accounts — no current-password field — and flips back to the normal change-password form once one is set. EN/TR i18n.

v0.2.0-beta.57

Dashboard

Fixed

  • Post-auth redirect now works end-to-end (professional `?redirect=` structure). Buying a domain from the public /domains page previously dumped users on the dashboard, losing the domain they picked — on both the login and logged-in paths. Now: the buy CTA is session-aware (logged-in users go straight to /dashboard/domains?domain=<name>; others to /register?redirect=…), registration finally consumes the saved redirect instead of hard-coding /dashboard, the login↔register cross-links carry the redirect along, and the dashboard auth guard captures the attempted URL so any deep link survives a login round-trip. A central sanitizeRedirectPath guard hardens every consumer against open redirects (absolute URLs, //host, backslash tricks, auth-page loops) — including the previously unsanitized login query param. The domains page pre-fills and auto-runs the search from ?domain= (kept in the URL as a shareable deep link). Verified with an end-to-end browser test: register with a picked domain → land on the domains page, search pre-filled and running.

v0.2.0-beta.56

Dashboard

Fixed (SEO audit follow-up)

  • `/domains` was invisible to Google. The page had no layout of its own, so it inherited the homepage's title, description and — critically — its canonical URL, telling Google it was a duplicate of /. It now has unique metadata, a self-referencing canonical, WebPage+Breadcrumb structured data, a sitemap entry, and nav + footer links (it was an orphan page reachable from nowhere).
  • Prices are now in the server-rendered HTML on `/pricing`. Plan prices previously existed only in the client-side data payload — AI crawlers and non-JS fetchers saw a pricing page with no prices. The page now fetches plans server-side (ISR, 1h) and seeds the client cache, so real dollar amounts render into the HTML; the interactive toggle still works as before. Title upgraded from generic "Pricing".
  • `/docs` had 10 `<h1>` tags — the 9 section headers are now <h2>, restoring a proper document outline for crawlers and AI section-extraction. Also: og:title separator aligned, TechArticle schema gains image/datePublished/dateModified.
  • `/changelog` split for Core Web Vitals: the page rendered 92 releases (~2,000 DOM nodes) in one document. It now shows the latest 30 with a link to the new /changelog/archive; CollectionPage + Breadcrumb structured data added.
  • Sitemap `lastmod` was one identical build timestamp for all 19 URLs — now per-route content dates (changelog keeps the build date, which is accurate for it).
  • Titles/metas: /features and /about got descriptive titles; /about's meta no longer promises "team" content the page doesn't have.
  • CSP was blocking Cloudflare Web Analytics (static.cloudflareinsights.com beacon 100% of loads) — now allowlisted. /vs/* schema gains datePublished/dateModified; global AggregateOffer gains highPrice; footer links got larger tap targets (WCAG).

v0.2.0-beta.55

Dashboard

Added

  • Domain management console (pairs with backend beta.56). Every purchased domain now has a Manage page with three tabs: DNS records (add/delete A, AAAA, CNAME, MX, TXT, SRV, NS with TTL/priority), Email forwarding (info@yourdomain → your inbox aliases), and Settings — transfer-lock toggle, custom nameservers (point at Cloudflare etc.), and an ICANN-compliant transfer-out section that reveals the EPP/auth code (with copy button and security warning).
  • Transfer a domain in. New dialog on the Domains page: enter the domain, get the live price (includes 1-year renewal), paste the auth code from your current registrar, and start — paid from credits, auto-refunded if the transfer is rejected. Pending/failed transfers show as status chips on the list.
  • Public `/domains` search page. Marketing-site domain search (rate-limited, no login needed) showing live availability and prices, with a "Sign up to buy" CTA — a Vercel-style acquisition funnel. EN/TR i18n throughout.

v0.2.0-beta.54

Dashboard

Added

  • Multi-year domain terms + pay by card (pairs with backend beta.55). The purchase dialog now has a 1/2/3/5-year term selector with a live total (year 1 at registration price, later years at renewal price) and two payment options: buy with credits as before, or Pay with card — a Stripe Checkout redirect that registers the domain automatically after payment (returning to the Domains page shows a "being registered" toast and refreshes the list). EN/TR i18n.