This month, we rebuilt the Infra Activity experience around a concise Terraform plan diff, so you can see exactly what changes on each resource without reading raw logs. We also brought cost reporting into every stack, environment, and block page, added the CLI pieces a CI pipeline needs to find and wait on a preview environment, and introduced Packaged Apps as a distinct kind of app. The catalog gained AlloyDB, Cloud KMS with customer-managed encryption for BigQuery, cross-account S3 access points, and scrubbed production Postgres snapshots. See more features and modules below.

# Product
- Infra Activity Redesign
- Cost Views on Every Stack, Environment, and Block
- CLI for CI Pipelines
- Packaged Apps
- Fixes & Smaller Improvements
## Infra Activity Redesign
The block and app Activity screen was rebuilt end to end.
Every workflow type now shares one layout, and the Terraform plan is rendered as a real diff instead of a list of resource names.
What's new:
- **Visual plan diff.** Resources are grouped by action with destructive changes first (destroy, replace, update, create), and each resource expands to show attribute-level `before → after` changes, `# forces replacement` markers, known-after-apply values, and masked sensitive values. Summary pills show counts by action and a filter narrows by address, type, or value
- **Concise / Raw toggle.** The visual diff and the raw ANSI log stream live behind one switch, so drilling into a plan and reading the full log are two clicks apart
- **First-class run states.** Planning, no changes, queued, plan failed (with Retry), and canceled each get a proper view instead of an empty grid
- **One header for every workflow.** Runs, builds, deploys, and IaC syncs share a single header with module version, commit, attribution, approval events, status, and resource-diff badges
- **Workflow rail.** A collapsible left rail separates the block's own plan from its dependencies, links up to the environment plan that spawned the run, and lets you jump into a dependency's workflow
- **Build and deploy terminals** gained wrap and download controls, and the **IaC Sync** view shows each `.nullstone/*.yml` file as a tab alongside a block-configuration change summary
## Cost Views on Every Stack, Environment, and Block
Cost reporting now shows up where you already are, not only on the home dashboard. [Set up cost reporting](https://docs.nullstone.io/integrations/overview.html#cost-reporting)
What's new:
- **Redesigned dashboard Cost Summary**
- **Stack page** costs filtered to the stack, grouped by block or by environment (your choice)
- **Environment page** costs filtered to the environment, grouped by block
- **Block page** costs filtered to the block, grouped by environment
- Unconfigured states point you to Cloud Accounts, and untagged spend rolls up into an **Unmanaged** bucket rather than disappearing
Both AWS and GCP cost providers feed these views.
## CLI for CI Pipelines
A pull-request pipeline can now locate its preview environment, wait for the app to be live, and run commands against it without hard-coding stack or environment names. [CLI reference](https://docs.nullstone.io/getting-started/cli/docs.html)
```
nullstone preview-apps find --repo=acme/widgets --pull-request=123
nullstone wait --for=deployed --stack=<stack> --env=<preview-env> --app=<app>
nullstone run --stack=<stack> --env=<preview-env> --app=<app> -- <command>
```
What's new:
- **`nullstone preview-apps find`** looks up preview apps by repository and pull request (number or id) and prints the stack, environment, and app that drive the rest of the workflow. An empty result exits non-zero so a script can branch on "no preview app found"
- **`nullstone wait --for=deployed`** succeeds as soon as the latest deploy has completed and nothing else is running. If the app is still launching, or the last deploy failed and a retry may be coming, it keeps polling until `--timeout` (default 1h) and then reports the failed deploy. Output is one line per status change plus the workflow URL
- **`nullstone envs list --type=<preview|pipeline|global|previews-shared>`** filters environments by type, with or without `--detail`
- **`nullstone outputs --field=<expression>`** prints a single raw output value for command substitution, using Terraform-style paths (`instance_id`, `endpoint.host`, `hosts[0]`). No more `-o json | jq -r`. A missing output exits non-zero with the list of available names
```
aws ssm start-session --target $(nullstone outputs --block=<app> --env=<env> --field=instance_id)
```
## Packaged Apps
Creating an app now distinguishes **generic app patterns**, where you bring your own code and the module supplies the infrastructure (Container, Serverless, Static Site, Server), from **Packaged Apps**, where the module ships a complete application you deploy and configure as-is.
Bastions, Tailscale subnet routers, and the GitHub Actions runner set are Packaged Apps.
They appear under their own tile in the create-app flow, no longer mixed in with the generic patterns, and creating one skips the repository and build configuration steps since there is no user code to connect.
For module authors, this is a new `app_type: packaged` field in `.nullstone/module.yml`.
It is orthogonal to `subcategory`, so packaged apps keep their real runtime archetype and every existing capability contract continues to match.
`nullstone modules find --app-type` filters on it, and existing modules default to `generic` with no change required.
## Fixes & Smaller Improvements
- **Removing a capability can no longer take the app down with it.** Capability removal runs a two-phase apply, and the first phase is usually a no-op plan. The engine used to skip applying no-op plans, but that apply is what rewrites resource dependencies in state, and without it the targeted destroy in phase two could expand to the app's own service, task definition, and secrets. Phase-one applies that precede a cleanup are now always executed, while no-op plans elsewhere are still skipped
- **Large module sources and IaC configs no longer fail workflows.** Runs against a module whose `source` pulled down a sizable git repository, or IaC syncs carrying a large set of `.nullstone` files, could fail with a payload-size error. Large blobs are now kept out of the workflow engine entirely
- **Approve / Disapprove buttons stay reachable** on the Infra Update pending-approval view when the plan output is wide, and wide Infra Activity content no longer clips the panel
# Catalog
### AlloyDB for PostgreSQL
`gcp-alloydb-postgres` brings Google's Postgres-compatible engine to Nullstone as a first-class GCP datastore, alongside `gcp-cloudsql-postgres`.
It registers under the same contract family (`datastore/gcp/postgres:alloydb`), so the existing `gcp-postgres-access` capability connects apps to it unchanged, and the db-admin function creates databases, roles, and grants exactly as it does for Cloud SQL.
What's new:
- Cluster plus primary instance sized by `cpu_count` or `machine_type`, with optional high availability, PSC connectivity, deletion protection, and point-in-time recovery
- A new `db_superuser_role` output (`alloydbsuperuser` here, `cloudsqlsuperuser` on Cloud SQL) that the access capabilities read, so one capability serves both engines
- Monitoring alerts rebuilt on AlloyDB's metrics: CPU, available memory, and connection count
One thing to know before reaching for it: AlloyDB has no shared-core tier. The smallest shape is a single vCPU, and the cluster carries always-on storage charges, so keep `gcp-cloudsql-postgres` as the default for preview and dev environments.
- [AlloyDB PostgreSQL Cluster](https://app.nullstone.io/registry/modules/nullstone/gcp-alloydb-postgres)
### GCP Cloud KMS and Customer-Managed Encryption
Three new modules let a GCP app provision and use Cloud KMS keys through Nullstone, and BigQuery datasets can now be encrypted with one.
- **`gcp-kms-keyring`** — a key ring block. Several keys with separate audiences can share one ring while keeping their IAM independent. The ring's location resolves from the environment (`{{ GCP_MULTI_REGION }}` by default) and is permanent
- **`gcp-kms-key`** — a symmetric `ENCRYPT_DECRYPT` key inside a connected ring, with 90-day rotation and a 30-day destroy window by default. Purpose and algorithm are fixed so a key that fails at runtime cannot be provisioned
- **`gcp-kms-access`** — a capability granting an app `cryptoKeyEncrypterDecrypter` (or encrypt-only / decrypt-only) on the key itself, never on the project, and injecting `KMS_KEY_ID`. Key-management roles are deliberately not grantable here
- **`gcp-bigquery-dataset`** gained an optional `cmek` connection. Connect a `gcp-kms-key` block and the dataset encrypts with it, with the BigQuery service-agent grant applied by the module, so there is no manual IAM step and no way to paste a key the dataset cannot use
- [Cloud KMS Key Ring](https://app.nullstone.io/registry/modules/nullstone/gcp-kms-keyring)
- [Cloud KMS Key](https://app.nullstone.io/registry/modules/nullstone/gcp-kms-key)
- [Cloud KMS Access](https://app.nullstone.io/registry/modules/nullstone/gcp-kms-access)
- [BigQuery Dataset](https://app.nullstone.io/registry/modules/nullstone/gcp-bigquery-dataset)
### GCS and BigQuery Location Alignment
`gcp-gcs-bucket` previously hardcoded its location to `US`, which meant a bucket and a `gcp-bigquery-dataset` in a non-US environment could never load into each other (BigQuery rejects cross-location loads outright), and EU-resident customers were handed a US bucket regardless of configuration.
Both modules now take a `location` that resolves from the environment's region:
- `{{ GCP_MULTI_REGION }}` — the default. `US`, `EU`, or `ASIA` for the bucket; `US` or `EU` for the dataset (BigQuery has no Asia multi-region)
- `{{ GCP_DUAL_REGION }}` — `NAM4`, `EUR4`, `ASIA1` (bucket only)
- `{{ GCP_REGION }}` — the environment's own region
- Any literal location still works
`gcp-gcs-bucket` also exposes `storage_class` (`STANDARD` default), and `gcp-bigquery-access` now injects `BIGQUERY_PROJECT` and `BIGQUERY_DATASET_LOCATION` alongside `BIGQUERY_DATASET`, so clients can build fully qualified `project.dataset.table` names.
**Breaking for non-US environments.** A bucket or dataset location is permanent, so a workspace outside `us-*` that does not pin `location` will see its resource replaced on the next apply. For the bucket that means the contents are destroyed; for the dataset the apply fails rather than dropping a non-empty dataset. `us-*` environments see no diff. Set `location = "US"` (bucket) or `location = "{{ GCP_REGION }}"` (dataset) to keep the previous behavior exactly.
- [GCS Bucket](https://app.nullstone.io/registry/modules/nullstone/gcp-gcs-bucket)
- [BigQuery Dataset](https://app.nullstone.io/registry/modules/nullstone/gcp-bigquery-dataset)
- [BigQuery Access](https://app.nullstone.io/registry/modules/nullstone/gcp-bigquery-access)
### Cross-Account S3 with Access Points
An app can now read from or write to an S3 bucket owned by another AWS account, with the bucket owner granting trust once per account rather than once per application.
- **`aws-s3-access-point`** — a new datastore that creates an S3 access point against a bucket in another account (or your own) and satisfies the same contract as `aws-s3-bucket`, so the existing `aws-s3-access` capability connects apps to it unchanged. Outputs the access point ARN for IAM and its alias, which works anywhere a bucket name is expected
- **`aws-s3-bucket`** gained `trusted_account_ids`, a list of accounts with `read` or `write` access that the consuming account can narrow but never widen, plus a new `kms_key` connection and `aws_account_id` / `kms_key_arn` outputs
- **`aws-kms-key`** — a new customer-managed KMS key datastore with `trusted_accounts`, since the AWS-managed `aws/s3` key has an immutable policy and can never be read from another account
- **`aws-s3-access`** now grants KMS encrypt/decrypt when the connected bucket exposes a key. This also fixes a same-account gap: on any CMK-encrypted bucket the capability previously produced a role that could call S3 but not decrypt
App-code impact is usually one line, swapping the bucket name for the alias.
Path-style addressing, hand-built `https://{bucket}.s3.amazonaws.com` URLs, and anonymous reads do not work through an access point, and `public_read_only` cannot be combined with cross-account trust.
- [S3 Access Point](https://app.nullstone.io/registry/modules/nullstone/aws-s3-access-point)
- [S3 Bucket](https://app.nullstone.io/registry/modules/nullstone/aws-s3-bucket)
- [KMS Key](https://app.nullstone.io/registry/modules/nullstone/aws-kms-key)
- [S3 Access](https://app.nullstone.io/registry/modules/nullstone/aws-s3-access)
### S3 Lifecycle Policies
`aws-s3-bucket` defaults to `versioning = true`, which meant every overwrite left a billable noncurrent version behind forever.
The module now accepts `lifecycle_policies`, a list of rules each with an optional filter (prefix, tags, object size) and any combination of:
- `expiration` — by days or date, or expired delete markers
- `transitions` — to `STANDARD_IA`, `ONEZONE_IA`, `INTELLIGENT_TIERING`, `GLACIER_IR`, `GLACIER`, or `DEEP_ARCHIVE`
- `noncurrent_version_expiration` and `noncurrent_version_transitions`, with a floor on how many recent versions to keep
- `abort_incomplete_multipart_upload_days`
The default is empty, so existing buckets plan no change. Prefer `GLACIER_IR` over `GLACIER` unless nothing reads the bucket directly, since `GLACIER` objects are unreadable until restored.
- [S3 Bucket](https://app.nullstone.io/registry/modules/nullstone/aws-s3-bucket)
### Scrubbed Production Snapshots for Postgres
**`pg-snapshot`** is a new open-source tool for loading realistic, scrubbed production data into lower environments.
It exports a production Postgres database with sensitive columns transformed *during* the export, so unscrubbed values never leave the source database, writes the result to S3 or GCS, and restores it into a target environment.
- **Scrub config is yours.** Name the sensitive columns and how to transform them (`null`, `md5`, `email`, `redact`, or any SQL expression); unlisted columns export as-is, and a rule naming a column that no longer exists fails the snapshot rather than silently skipping. Deterministic transforms keep foreign keys over scrubbed columns resolving
- **Restore is a swap, not an overwrite.** The snapshot loads into a fresh `restored_<id>` database, your own migration command reconciles schema drift, and two catalog renames swap it into place while keeping the previous database as a timestamped backup. A failure before the swap leaves the target untouched
- **No superuser, no `session_replication_role`.** Foreign keys and triggers are created after the data lands, so nothing needs to be disabled, which is what makes this work on RDS and Cloud SQL
- **`aws-postgres-restore-access`** / **`gcp-postgres-restore-access`** — new capabilities that mint the instance-admin restore role, ensure the target database and owner exist, and publish `RESTORE_TARGET_DATABASE` and `RESTORE_OWNER_ROLE` to the restoring app. Production gating is structural: attach the capability only in environments that restore, and the role exists nowhere else
- [pg-snapshot on GitHub](https://github.com/nullstone-io/pg-snapshot)
- [Postgres Restore (AWS)](https://app.nullstone.io/registry/modules/nullstone/aws-postgres-restore-access)
- [Postgres Restore (GCP)](https://app.nullstone.io/registry/modules/nullstone/gcp-postgres-restore-access)
### Smaller Module Updates
- **`aws-eks-standard`** exposes the managed node group's **node auto repair** settings (`enabled`, parallel-repair and unhealthy-threshold limits, per-condition overrides) plus `node_labels`, `node_taints`, `node_max_unavailable`, and `node_force_update_version`. This also fixes a perpetual plan diff where EKS's server-side repair defaults were nulled out on every plan
- **`aws-sqs-fifo`** gained `deduplication_scope` (`queue` or `messageGroup`) and `fifo_throughput_limit` (`perQueue` or `perMessageGroupId`) for high-throughput FIFO mode, applied to both the primary and dead-letter queues. A plan-time check enforces that `perMessageGroupId` requires message-group deduplication. Defaults match AWS, so existing queues are unaffected
- **`aws-postgres-access`** / **`gcp-postgres-access`** accept a `username` override for the generated Postgres role. Leave it blank to keep a unique generated role: two apps sharing one role name reset each other's password on launch
- [EKS Standard](https://app.nullstone.io/registry/modules/nullstone/aws-eks-standard)
- [SQS FIFO](https://app.nullstone.io/registry/modules/nullstone/aws-sqs-fifo)
- [Postgres Access (AWS)](https://app.nullstone.io/registry/modules/nullstone/aws-postgres-access)
- [Postgres Access (GCP)](https://app.nullstone.io/registry/modules/nullstone/gcp-postgres-access)