Configure your app from the repo
Put a deploy/ folder in your repo and every push applies it, with a Postgres database and a bucket declared next to the app. The data stays when the file goes.
Currently available on selected dedicated instances. Coming to the shared platform next.
Your app’s configuration can live in the repo, next to its code. Put the manifests in a deploy/ folder at the repository root, and every push to the branch the app watches applies them. Replicas, environment, a Postgres database and an object storage bucket are then versioned with the code and reviewed like code, and the console shows which commit the running configuration came from.
The deploy/ folder
nyxory reads the *.yaml and *.yml files at the top level of deploy/. Without the folder nothing changes, and the platform keeps generating the app’s configuration as before. Other folders such as k8s/, manifests/ or charts are not read on push, so move the manifests you want to own into deploy/.
deploy/ is always read at the repository root, also when an app builds from a subfolder. In a monorepo every app therefore reads the same folder, and in one project a push of a second app from that repository is refused, because the objects it names already belong to the first app.
| Kind | What the platform does |
|---|---|
Deployment |
applies it |
Service |
applies it, ClusterIP only and with a selector |
ConfigMap |
applies it |
Ingress |
maps each rule onto the app’s own domain, so TLS and protection of the public address stay with the platform |
Postgres |
provisions a database for the project and keeps it |
Bucket |
provisions a bucket for the project and keeps it, on instances with object storage |
Postgres and Bucket use apiVersion: nyxory.com/v1alpha1, the others their usual apps/v1, v1 and networking.k8s.io/v1.
The app container runs the image nyxory built from the pushed commit. Write image: IMAGE_REF where that image belongs; other images, a redis:7 sidecar for example, stay as written. Without an IMAGE_REF, the first container of the first Deployment gets the built image. Namespace, platform labels and ownership are set by the platform, so leave them out.
# deploy/app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels: {app: web}
template:
metadata:
labels: {app: web}
spec:
containers:
- name: web
image: IMAGE_REF
ports: [{containerPort: 8080}]
env:
- {name: GREETING, value: "hello"}
---
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector: {app: web}
ports: [{name: http, port: 80, targetPort: 8080}]
Keep the names the first deploy created, usually the app’s name. A new name creates a second Deployment next to the first one.
Start from the generated configuration
The app page in the console shows under Configuration the manifests the app runs with right now, read from the cluster, one object at a time. Copy puts the selected object on the clipboard, and you paste it into a file in deploy/ as a starting point. Runtime fields are already stripped and the names are the ones the first deploy created. Before you push, set image: IMAGE_REF, remove metadata.namespace and every label or annotation under nyxory.com, and leave out kinds the table above does not list. Values with key-like names come out masked and Secrets are not shown. Store those values with nyx_set_secret and reference the Secret by name.
What happens on a push
Push to the watched branch and nyxory builds the image from the new commit, as with any deploy on push. Then it checks every object in deploy/, runs a dry run of all of them, and only when everything passes does it apply the folder and roll out the new image. The app page in the console shows the result under Configuration as “Source: from your repo, deploy/ @ <commit>”.
Nothing is pruned. An object the generated configuration had and deploy/ does not name stays in place, and so does every field deploy/ never sets.
A refused push leaves the app running
When one object breaks a rule, nyxory refuses the whole push before anything is written, and the running version keeps serving. The app page says so under Configuration, with the file and the reason:
Last push rejected, nothing applied,
the running version stays:
deploy/postgres.yaml: Postgres orders, tier dedicated
is not available yet; use tier: shared
Fix the file and push again. These are always refused:
- a
Secret, since secret values never come from the repository (your coding agent stores them withnyx_set_secret, anddeploy/references them by name) - any kind not in the table above, RBAC and cluster-wide objects included
- a
metadata.namespaceother than the project’s, and labels or annotations undernyxory.com traefik.*and platform-owned annotations on an Ingress, and an Ingress host that is not one of the app’s addresses (add the domain first)- host namespaces,
hostPathvolumes, privileged containers, and aserviceAccountNameother than the app’s own - a Service of type
LoadBalancerorNodePort,externalIPs, and names reserved for the platform (nyx-*,nyxory-*) - a symlinked
deploy/folder or file
The folder holds about 10 KB of YAML. A larger one fails the build with a message saying so.
Your coding agent
With the nyxory MCP connected, your coding agent calls nyx_config_reference before it writes deploy/. The answer lists the allowed kinds, the fields of Postgres and Bucket with type, default and description as installed on your instance, the rules a push is checked against, and an example per kind. The whole documentation is also one file at nyxory.com/llms-full.txt.
The platform does not commit to your repository. Its agent works on a read-only clone. When a fix needs a change to deploy/ or to other files you wrote, it shows the change, and you or your coding agent commit and push it.
A Postgres database: deploy/postgres.yaml
Declare the database next to the app and nyxory creates it on the platform’s database server, with two roles and two Secrets in your project. Nobody writes a password anywhere.
# deploy/postgres.yaml
apiVersion: nyxory.com/v1alpha1
kind: Postgres
metadata:
name: orders
spec:
tier: shared
| Field | Values |
|---|---|
tier |
required, and shared is the only value from deploy/ |
secretName |
optional, defaults to <name>-postgres, fixed after the first push |
auth |
password (default) or certificate, see below |
deletionPolicy |
ignored, data from the repo is always kept |
Any other field, and any label or annotation under nyxory.com, refuses the push.
The two Secrets. orders-postgres is for the running app. Its role reads and writes rows and cannot change the schema. orders-postgres-migrate is for the migration and owns the schema. Both carry the same keys:
| Key | Holds |
|---|---|
DATABASE_URL |
the full connection string, with sslmode=verify-full |
PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD |
the same values one by one, for tools that read no URL |
PGSSLMODE, PGSSLROOTCERT |
verify-full and /etc/nyxory-postgres/ca.crt |
ca.crt |
the database server’s CA certificate |
PGHOST_RO |
a read-only replica host, only when the server runs more than one instance |
The Deployment reads the Secret with envFrom and mounts it, so the client finds the CA the connection string verifies against:
spec:
containers:
- name: web
image: IMAGE_REF
envFrom:
- secretRef: {name: orders-postgres}
volumeMounts:
- name: pgca
mountPath: /etc/nyxory-postgres
readOnly: true
volumes:
- name: pgca
secret: {secretName: orders-postgres}
The Secret appears a few seconds after the push. A pod that starts earlier waits for it and starts on its own once it is there.
Migrations stay a Job. Jobs are not allowed in deploy/, so your coding agent runs the migration as its own Job through nyx_deploy_manifests, with the -migrate Secret. A framework that migrates on boot fails with the app role, which is the separation working. Move that step into the Job.
Sharing a database. One app declares it, others read it. A second app that declares a database of the same name is refused. It reads the first app’s Secret with envFrom instead, and its page shows the database as “used, declared by <app>”.
A bucket: deploy/bucket.yaml
On instances with object storage, a bucket is one more file. Elsewhere the push is refused before anything is written, with “object storage is not set up on this instance”.
Buckets are available on instances on AWS and STACKIT. On AWS every bucket is created with encryption at rest (SSE-S3) and with public access blocked.
# deploy/bucket.yaml
apiVersion: nyxory.com/v1alpha1
kind: Bucket
metadata:
name: reports
spec:
versioning: false
credentials: auto
| Field | Values |
|---|---|
versioning |
true or false (default), true keeps overwritten objects |
credentials |
auto (default), keys or identity |
secretName |
optional, defaults to <name>-s3, fixed after the first push |
deletionPolicy |
ignored, data from the repo is always kept |
auto picks the strongest access the instance offers. On AWS that is identity, where the app reaches the bucket through the identity of its pod and no key exists anywhere. On other providers it is keys, an access key pair in the Secret. A mode the instance does not offer puts the bucket into an error with reason CredentialsUnavailable and never falls back quietly.
For access by identity the pod runs under the app’s own ServiceAccount, which carries the app’s name:
spec:
serviceAccountName: web
containers:
- name: web
image: IMAGE_REF
envFrom:
- secretRef: {name: reports-s3}
The Secret reports-s3 carries the same names in both modes, so the app code does not change between them:
| Key | Holds | Mode |
|---|---|---|
S3_BUCKET, S3_REGION, S3_ENDPOINT |
bucket name, region, endpoint | both |
S3_FORCE_PATH_STYLE |
false on AWS, true where the endpoint is path-style |
both |
AWS_REGION, AWS_ENDPOINT_URL_S3 |
region and endpoint under the names AWS SDKs read by themselves | both |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY |
the access key pair | keys only |
Build the S3 client with the SDK’s default credential chain and read only the bucket name. The chain finds the key pair in keys mode and the pod identity in identity mode.
import os, boto3
# region, endpoint, credentials: the default chain
s3 = boto3.client("s3")
s3.put_object(Bucket=os.environ["S3_BUCKET"],
Key="hello.txt", Body=b"hi")
import {
S3Client, PutObjectCommand,
} from "@aws-sdk/client-s3";
const env = process.env;
const s3 = new S3Client({
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
});
await s3.send(new PutObjectCommand({
Bucket: env.S3_BUCKET,
Key: "hello.txt",
Body: "hi",
}));
In identity mode, pods that were already running when access was set up get restarted once by the platform, and the bucket shows “waiting for access” meanwhile. The boundary is the project. Every app in the project reaches the project’s buckets, and no app in another project does. Bucket names on AWS are global, so a name taken elsewhere ends in reason BucketNameTaken, and a different metadata.name fixes it.
Data from the repo is kept
Databases and buckets declared in deploy/ belong to the project, not to the app:
- Removing the file keeps the data. The app page shows “No longer in deploy/, last declared in <commit>. The data stays.”
- Deleting the app keeps it too. Other apps that read its Secret keep working.
deletionPolicy: Deletein the file is ignored, and the app page notes it.- Checks run before anything is written. nyxory checks the per-project limit for databases and buckets, that the Secret name is free, and that
tierandsecretNameof an existing database are unchanged.
Deleting is its own, deliberate step, for workspace owners and admins. On the app page, Delete database or Delete bucket asks you to type the name to confirm, or your coding agent does the same with nyx_data (type: postgres or type: bucket) after your approval. A bucket is emptied and removed. A database is dropped after a final dump, and on an instance without a dump target it stays until one is set up, which the dialog tells you. Take the file out of deploy/ first, otherwise the next push creates the object again, empty.
A database or bucket your coding agent created earlier for this app is taken over when deploy/ declares it with the same name, the same Secret and, for a database, the same tier. From then on deploy/ owns it, and changes go through the repo.
Postgres with a client certificate
Optionally, a database logs in by client certificate instead of a password:
spec:
tier: shared
auth: certificate
Neither role has a password any more. Each pod of the declaring app gets a client certificate for its role, mounted as files under /etc/nyxory-postgres-cert and never stored in a Secret. The certificate lives 72 hours and the platform replaces it after 48. On an instance that does not offer certificate login, the database reports reason CertificateAuthDisabled and nothing changes.
The Secrets change shape. PGPASSWORD is gone, DATABASE_URL carries sslcert and sslkey, and PGSSLCERT and PGSSLKEY point at the two files. A Deployment in deploy/ that reads the Secret with envFrom gets the certificate volume, the CA mount and the app’s ServiceAccount added automatically, so the envFrom line stays the same as with a password. The platform adds them on a push once the database exists. When the database and the Deployment arrive in the same first push, push once more after the database is ready.
A connection that is open keeps working when the certificate rotates. Every new connection has to present the current file, and drivers differ in how they read it:
| Driver | Reads the new certificate | What to do |
|---|---|---|
libpq: psql, psycopg 2 and 3, Django, SQLAlchemy on psycopg, Rails pg |
on every connect | nothing, DATABASE_URL or PGSSLCERT/PGSSLKEY as they are |
node-pg (pg) |
on every new client built from connectionString |
pass connectionString: process.env.DATABASE_URL and do not read the files into an ssl object once at start |
| pgx (Go) | never, ParseConfig loads the files once |
clear TLSConfig.Certificates and set GetClientCertificate to load PGSSLCERT and PGSSLKEY with tls.LoadX509KeyPair on each call, otherwise the pool fails once the first certificate expires |
| pgjdbc (Java, Spring, Hikari) | on every new physical connection | pass sslrootcert, sslcert and sslkey from the environment on the JDBC URL |
| asyncpg, Prisma, npgsql | not verified | stay on auth: password |
In certificate mode a database is not shared. Only the app that declares it receives the certificate for the app role, and the migration Job runs under a ServiceAccount named like the migration Secret, orders-postgres-migrate, which the platform creates for it. Since the Job goes through your coding agent and not through deploy/, it carries that serviceAccountName and the certificate volume itself. Switching between password and certificate is a change in the file, and roles keep their data and grants. The certificate volume follows the mode the database had when the push was applied, so push once more after the database reports the new mode, in either direction.
Why a certificate and not a token. Postgres checks credentials only when a connection opens, and most drivers and pools read the password once at start, so a token that changes every hour breaks on the next reconnect. A certificate is read from disk when a connection opens, which is exactly when Postgres checks it, and Postgres verifies it natively.
Not in deploy/ yet
- settings only one provider knows, such as your own encryption key or retention periods for a bucket
- rolling out the same change to several instances at once
- several apps in one repository, each with its own
deploy/
Tell us which you need.
Related: Deploy on push · Storage and backups · Deploy from a private repo