# Getting Started
> Create your account in the control panel, set up a project, issue an API key, and make your first API call.
Everything on this page starts in the browser. You create your account, your
project and your first API key in the control panel — the API is what you use
*afterwards*, once you have a key to authenticate with.
## 1. Create an account
Go to [cosmoner.com/signup](https://cosmoner.com/signup) and fill in the form,
or use **Continue with GitHub** to sign up with your GitHub account instead.
Passwords must be at least 8 characters. No card is collected at signup — you
add one per project, later, at the point you actually deploy something.
After submitting, check your inbox and click the verification link. You cannot
sign in until your email address is verified.
Creating an account is not part of the API: sign up in the browser, where the
flow includes email verification and GitHub sign-in. The same goes for resetting
a forgotten password — see [Account Security](/docs/guides/account-security#forgotten-your-password).
## 2. Create a project
Once you are signed in you land on the control panel. Projects are workspaces
for your apps, team members, and billing — every resource you create later
(apps, servers, domains, databases, buckets, registries) lives inside one, so
this is the first thing to make.
Click **New project** on the control panel home, or pick **New project** from
the project switcher in the navbar:
Only the name is required. Use something descriptive — `auth-service`,
`marketing-website` — since you can run several projects to keep different sets
of apps and team members apart.
Inviting teammates is optional. Each address you add gets an email invitation to
join the project; **Add another** gives you an extra row. You can skip this
entirely and invite people later from the project's settings.
After you submit, you are offered a payment method step. You can skip that too,
but a project cannot deploy any billable resource until it has a card on it, and
only the project's biller can manage that card. See the
[Billing guide](/docs/guides/billing-and-payments) for how project billing
works.
## 3. Create an API key
API keys are what you use for scripts, CI pipelines and integrations. They are
issued from the control panel — this is the only way to get your first one.
Go to **Account → API keys → Create key**, name the key, and tick the
permissions it needs:
Permissions are a resource plus an action. Tick only what the key needs — a
deploy pipeline usually wants `apps: read, write` and nothing else.
**A key with no permissions ticked cannot call any scoped endpoint.** Leaving
the grid empty does not grant full access; it grants none, and every scoped
route answers `403 INSUFFICIENT_SCOPE`.
When you submit, the key is shown to you exactly once:
Copy it now and store it in your secret manager or CI secrets. Leaving the page
without copying it means creating a new key. Keys start with `cos_pk_`.
See the [API Keys guide](/docs/guides/api-keys) for the full scope list and for
rotating or revoking a key.
## 4. Make your first API call
Pass the key as a bearer token:
```bash
curl https://api.cosmoner.com/v1/projects \
-H "Authorization: Bearer cos_pk_your_key_here"
```
```json
{ "success": true, "data": [{ "id": "...", "name": "northwind-labs" }] }
```
That is the whole authentication story for the API — there is no separate key
id and secret, and no token exchange step.
## 5. Start building
Prefer a client library to raw HTTP? The [SDKs](/docs/sdk) for JavaScript,
Python and PHP handle authentication, retries and errors for you.
* **Deploy an app** — see [Deploy an App from Git](/docs/guides/deploy-an-app),
[Deploy a Next.js App](/docs/guides/deploy-nextjs) or
[Deploy a Container Image](/docs/guides/deploy-container-image), and the
[Apps API](/docs/api/apps)
* **Find a domain, or verify one you own** — see the [Domains API](/docs/api/domains)
* **Set up email sending** — see the [SMTP Setup guide](/docs/guides/smtp-setup)
* **Add a database** — see the [Databases guide](/docs/guides/databases)
* **Add a cache** — see [Connect to Redis](/docs/guides/redis)
* **Manage buckets and container images** — see the [Storage API](/docs/api/storage)
* **Push container images** — see the [Container Registry guide](/docs/guides/container-registry)
* **Provision a server** — see the [Servers API](/docs/api/servers)
## Base URL
All API requests go to:
```
https://api.cosmoner.com
```
## Response format
Every response follows the same envelope:
```json
{ "success": true, "data": { ... } }
```
```json
{ "success": false, "error": { "code": "VALIDATION_ERROR", "message": "..." } }
```
Standard HTTP status codes are used: `200`, `201`, `400`, `401`, `403`, `404`,
`422`, `429`, `500`, `502`, `503`. The codes you are most likely to meet while getting
started are `401 UNAUTHORIZED` (missing or invalid key) and
`403 INSUFFICIENT_SCOPE` (the key is valid but lacks the permission the route
requires) and `403 INSUFFICIENT_ROLE` (the role you, or the key's service
account, hold in the project does not allow the request).
`503` is always temporary — retry the request after a short pause. `502`
(`BAD_GATEWAY`) means a service the request depends on failed; it is usually
temporary too.
## Errors
Branch on `error.code`, not on `error.message` — codes are stable, while the
wording of messages may change. When an error is one you can fix on your side,
the body also carries a `docsUrl` linking to the section that explains the fix:
```json
{
"success": false,
"error": {
"code": "INSUFFICIENT_SCOPE",
"message": "API key does not have apps:write permission",
"docsUrl": "https://cosmoner.com/docs#insufficient_scope"
}
}
```
`docsUrl` is optional. Errors with nothing for you to change, such as
`NOT_FOUND` or `INTERNAL_ERROR`, leave it out. Some errors add further fields
next to `code` and `message`, such as `issues` on a validation error.
### `UNAUTHORIZED`
`401` — the request carried no credential the API accepts. Either the
`Authorization` header is missing or not in the form `Bearer `,
or the key in it has been revoked or has expired.
Check that your script or pipeline actually reads the key from wherever you
stored it, and sends it as shown in
[Using API Keys](/docs/guides/api-keys#using-api-keys). If the key was revoked
or has expired, create a new one.
### `INVALID_API_KEY`
`401` — the API key in the request could not be verified. It may be mistyped or
truncated, or it may have been revoked or expired. Copy the key again from your
secret store, or create a replacement and
[revoke](/docs/guides/api-keys#revoke-an-api-key) the old one.
### `INSUFFICIENT_SCOPE`
`403` — the key is valid, but it does not carry the permission the route
requires. The message names the missing permission, for example
`apps:write`.
Add that permission to the key — in the control panel under
**Project → API keys**, or with
[`PATCH /v1/projects/:projectId/api-keys/:id`](/docs/guides/api-keys#update-an-api-key),
which replaces the key's permissions with the full set you send. See
[Permission Scopes](/docs/guides/api-keys#permission-scopes) for what each
permission covers. A key created with no permissions ticked has none at all and
gets this error on every scoped route.
### `INSUFFICIENT_ROLE`
`403` — the request is allowed by the key's scopes, but not by the role behind
it: yours for a signed-in session, a CLI login or a personal access token, the
service account's for a project API key. A developer cannot change members,
plans, API keys, secrets, variables or project settings, and only an owner can
delete a project. The `details` field names the permission refused and the role
that holds it. See [Team Roles](/docs/guides/team-roles) for what each role can
do; an owner or admin can change a role.
### `API_KEY_WRONG_ORGANIZATION`
`403` — the key belongs to a different project than the one in the request
path. A key can only act on the project it was created in. Check the project id
in the URL, or use a key created in the project you are addressing.
### `API_KEY_NOT_ALLOWED`
`401` — the action can only be taken by a person signed in to the control
panel, not with an API key. This applies to inviting, removing and changing
the role of a member, to viewing and answering your own invitations, to signing
or ending a service agreement, and to account and sign-in actions. Do it in the
control panel instead.
### `VALIDATION_ERROR`
`422` — the request body, path parameters or query parameters did not match
what the endpoint accepts. The `issues` field mirrors the shape of your input:
`errors` lists the problems at each level, `properties` holds one entry per
object field, and `items` one per array element.
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"issues": {
"errors": [],
"properties": { "name": { "errors": ["Too small: expected string to have >=1 characters"] } }
},
"docsUrl": "https://cosmoner.com/docs#validation_error"
}
}
```
Fix the fields it names and send the request again. Retrying it unchanged
returns the same error. The endpoint's page in the API reference, and the
[OpenAPI document](#machine-readable-reference), list the fields it accepts.
### `RATE_LIMITED`
`429` — you are sending requests faster than the API allows, and this one was
rate-limited. Wait before retrying. If the response carries a `Retry-After`
header, wait at least that many seconds; otherwise back off, increasing the
wait after each refusal. Spreading requests out, or reusing results rather than
fetching them again, keeps you clear of the limit.
### `ACCOUNT_BANNED`
`403` — the account this API key or session belongs to has been suspended, so
every request made with it is refused. Retrying will not help. Contact support
if you believe this is a mistake.
### Billing errors
Billing errors link to the [Billing & Payments guide](/docs/guides/billing-and-payments):
* `402 ORG_PAYMENT_METHOD_REQUIRED` and `402 BILLER_PAYMENT_METHOD_REQUIRED` —
the project has no card to charge, so it cannot deploy a billable resource.
See [The Project Payment Method](/docs/guides/billing-and-payments#the-project-payment-method).
* `403 ACCOUNT_BLOCKED` — an unpaid invoice has blocked the project or account.
See [Resolving a Blocked Account](/docs/guides/billing-and-payments#resolving-a-blocked-account).
## Machine-readable reference
The API describes itself as an OpenAPI 3.0 document:
```
https://api.cosmoner.com/v1/openapi.json
```
It is generated from the same schemas the API validates requests against, so it
does not drift from the behaviour it describes. Point your own code generator at
it, or load it into an API client, to work against the endpoints it covers.
Endpoints still documented only on the pages above are not in the document yet;
it grows as they are migrated.
## Docs for AI assistants and agents
These docs are published in forms a language model can read directly:
* **[`/llms.txt`](https://cosmoner.com/llms.txt)** indexes every page with a
one-line summary, following the [llms.txt](https://llmstxt.org) convention.
* **[`/llms-full.txt`](https://cosmoner.com/llms-full.txt)** is every page in
one Markdown file, for tools that would rather load everything at once.
* **Any docs page as Markdown:** add `.md` to its URL —
`https://cosmoner.com/docs/guides/secrets.md` — or request the page with an
`Accept: text/markdown` header.
Each page also has a **Copy Markdown** button under its title, and an **Open**
menu that hands the page to ChatGPT, Claude or Cursor.
### Docs MCP server
AI tools that support the [Model Context Protocol](https://modelcontextprotocol.io)
can search and read these docs directly. Add this URL as a remote MCP server —
no API key or sign-in is needed:
```
https://cosmoner.com/docs/mcp
```
In Claude Code, for example:
```bash
claude mcp add --transport http cosmoner-docs https://cosmoner.com/docs/mcp
```
The server offers three read-only tools:
* **`search_docs`** finds the pages and sections that match a query.
* **`get_doc_page`** returns a page as Markdown — the same text as its `.md` URL.
* **`list_doc_pages`** lists every page with a one-line summary, like `/llms.txt`.
It reads the public docs only. It cannot see or change anything in your
account. It is rate-limited per client: a caller that sends too many requests
too quickly gets `429 Too Many Requests` with a `Retry-After` header saying how
many seconds to wait.
To let an agent work on your project itself — read app logs, redeploy, set
variables — connect the [platform MCP server](/docs/guides/mcp) with an API
key instead.
Source: https://cosmoner.com/docs
---
# Deploy an App from Git
> Connect GitHub, GitLab or Bitbucket, pick a repository, and have your app built and running on a cosmoner.com address — then redeployed on every push.
## Overview
An app is built from a branch of your repository and runs as a web service or
a static site. You connect your git provider once, choose the repository, and
Cosmoner builds it — either by detecting your stack or by running your
`Dockerfile` — and serves it over HTTPS.
The whole flow is three steps in the control panel: **Domain**, **Application**
and **Configure**. Open your project, go to **Apps**, and select **New app**.
## Before you start
* A project with a payment method. Apps are billed monthly from the plan you
choose — see [Apps Pricing](/docs/pricing/apps).
* Your code in a repository on **GitHub**, **GitLab** or **Bitbucket**.
* For a web service: a process that listens for HTTP on the port in the `PORT`
environment variable. Most frameworks already do — see [Ports](#ports).
No code yet? Start from one of the [starter templates](/docs/guides/deploy-from-a-template):
copy it to your GitHub and deploy your copy.
Deploying straight from a public repository URL without connecting a provider is
not supported. If you would rather not connect one, build an image yourself and
[deploy it as a container image](/docs/guides/deploy-container-image).
## 1. Choose a domain
Pick where the app will be reachable:
* **Use a free cosmoner.com address.** The app gets a generated address such as
`my-app-3f9a.cosmoner.com` at no extra charge, shown once it is deployed.
* **Select an existing domain.** Any active domain in your project, as the root
domain or under a subdomain you name, e.g. `app.example.com`.
You can switch between the two later, from the app's **Network** tab with
**Change public URL**. An app has one public address at a time.
## 2. Choose the repository and how it builds
Under **Git Repository**, choose your provider and connect your account the
first time. GitHub is connected by installing the Cosmoner GitHub App on the account
or organization that owns the repository; GitLab and Bitbucket are connected
by signing in. Once connected, pick the repository and the **Branch** to
deploy.
If the repository has a `.cosmoner/deployment.yaml`, the wizard offers **Fill in
from file** — see [App Configuration Files](/docs/guides/app-templates). Every
setting below can also be filled in by hand.
### Source
**App directory** is the folder the app is built from. Leave it at the root for
a single app; for a monorepo, point it at the service, e.g. `apps/api`.
### Build
Choose the app type:
| Type | What it is |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| **Web Service** | A process that runs continuously and answers HTTP — an API, a server-rendered site, a backend. Runs on the plan you choose. |
| **Static Site** | Files built once and served as they are — a site built with Vite, Astro or a static-site generator. Flat monthly price, no plan to choose. |
And the build strategy:
| Strategy | When to use it |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Automatic** | Your stack is detected from the repository — Node.js, Next.js, Python, Go, Ruby, PHP, Java, .NET, Rust and more — and built without any extra files. The recommended default. |
| **Dockerfile** | Your repository has a `Dockerfile` in the app directory and you want it used exactly as written. Not available for static sites. |
**Build command** and **Run command** are detected for you with **Automatic**;
set them only to override what was detected, e.g. `npm run build` and
`npm run start`.
A static site takes an **Output directory** instead — the folder your build
writes the site into, `dist` by default. Its build command runs in a clean
Node.js 22 environment holding your files and nothing else, so install your
dependencies as part of it: `npm ci && npm run build`. A site with nothing to
build needs no command; the output directory, or the app directory when none
is set, is served as it is.
### Ports
A web service is given its port in the `PORT` environment variable. If your app
listens on `$PORT`, leave **Port** empty. If it listens on a fixed port instead,
the port is chosen in this order:
1. The **Port** you set.
2. The port in your `Dockerfile`'s `EXPOSE`, for Dockerfile builds.
3. `8080`.
### Environment variables
Add what your app needs to run. Each row can be:
* a plain value, with **Add variable**;
* a stored secret, with **Link secret** — the value stays in the vault and is
read when the app deploys, so rotating the secret needs no change to the app;
* a project variable, with **Link variable**;
* or several at once, with **Import variables** from a `.env` file or pasted
text.
Variables are available **while the app builds** as well as while it runs, so
values a framework bakes into the build — `NEXT_PUBLIC_*`, `VITE_*` — work. The
build log lists variable names, never their values. See
[Secrets](/docs/guides/secrets) for storing sensitive values.
### Deployment
**Auto-deploy on push** is on by default: every push to the selected branch
builds and deploys a new version. Turn it off to deploy only when you ask.
## 3. Configure and launch
Name the app, then choose:
* **Plan** — the instance size for a web service: vCPU, memory and a monthly
bandwidth allowance for one fixed price. Static sites skip this.
* **Region**.
* **Instances** — how many copies of the service run behind the address. Each
instance is billed at the plan's price.
* **Database**: optionally, a PostgreSQL database created with the app. See
[below](#create-an-app-with-a-database).
The summary shows the monthly price and what is charged today. Select
**Launch** to start the first build.
### Create an app with a database
A web service can get its own PostgreSQL database in the same step. Select
**Add a PostgreSQL database** and choose:
* **Plan**: the cheapest is selected to start with. Each plan shows its price.
* **Region**: the app's region, when databases are offered there.
* **Variable in your app**: the environment variable your app reads the
connection string from, `DATABASE_URL` unless you change it.
* **Stored as secret**: the name of the [project secret](/docs/guides/secrets)
that holds the connection string, also `DATABASE_URL` by default. It must be
a name the project does not use yet: an existing secret is never overwritten,
so if you already have a `DATABASE_URL`, choose another name here and keep
the variable name your app expects.
When you launch, the database is created and billed exactly as one you create
from **Databases**, and the summary shows both prices. Its connection string is
stored as the secret and linked into the app's environment, like any linked
secret, and the app is linked to the database. Static sites cannot have one.
The app's first build waits until the database is online, usually a few
minutes, so the app never starts without a database to connect to. Its page
says it is waiting meanwhile, and you can leave it.
If the database cannot be created, the app is not deployed and its page says
why. The failed database is removed and you are not billed for it. Select
**Retry database** to create it again: the app keeps the same secret and
deploys once the new attempt is online. Creating an app with a database, and
retrying one, needs the developer, admin or owner role, and an API key needs
both `apps:write` and `databases:write`.
## After it launches
The app's detail page has everything you need while it runs:
| Tab | What it is for |
| ---------------- | ---------------------------------------------------------------------- |
| **Build logs** | The output of each build. The first place to look when a deploy fails. |
| **Runtime logs** | What your app writes to stdout and stderr. Static sites have none. |
| **Network** | The public address, custom domains and their certificate status. |
| **General** | Build settings, environment variables, auto-deploy and the plan. |
| **Metrics** | CPU, memory and restarts. |
HTTPS is set up for you, on the cosmoner.com address and on custom domains
alike.
### Redeploying
With auto-deploy on, a push is a deploy. To build the current branch again
without pushing, select **Redeploy** in the app's header.
Changing an environment variable on the **General** tab saves it, but the
running app keeps the old value until the next deploy — redeploy to apply it.
The same goes for anything baked in at build time.
### Changing the instance count
Set **Instances** on the **General** tab and save. Each instance is billed at
the plan's price, so adding instances is charged when you save; see
[Apps Pricing](/docs/pricing/apps#changing-the-instance-count). More instances
also need room to run on. If there is none right now, or the payment method is
declined, the save is refused with a message saying so, and none of the
settings in that save are changed. Lowering the count is always possible.
### How instances stop
A deploy, a rollback or a lower instance count replaces running instances, and
each one stops the same way. It is taken out of rotation and keeps running for
5 seconds, so requests already on their way to it still arrive. Then it is sent
`SIGTERM`, and has what is left of 30 seconds from the start of the stop to
finish the requests it is serving and exit; anything still running after that
is killed.
Handle `SIGTERM` to finish cleanly. A server that does not handle it exits the
moment the signal arrives, dropping whatever it was in the middle of.
The signal reaches your server even when a shell, `npm start` or a start script
launched it, for apps built with **Automatic** and for any app with a **Run
command**. A **Dockerfile** app without a run command starts exactly as its
Dockerfile says, and the container's main process is the only one signalled:
make that your server, with the exec form `CMD ["node", "server.js"]` or an
`exec` at the end of a start script, and handle `SIGTERM` in it — a container's
main process ignores the signal unless it does.
### Rolling back
The **Deployments** section of the app's **Overview** tab lists its recent
deployments and marks the one that is running. Any earlier deployment that went
live has **Roll back**, which puts the app back on the image that deployment
ran — immediately, without building anything.
Only the code goes back. The app keeps its current environment variables and
settings, so if a bad deploy came with a variable change, change that back too.
With auto-deploy on, your next push moves the app forward again as usual.
Rolling back is not available while another deployment is in progress. Through
the API, it is [`POST .../deployments/{deploymentId}/rollback`](/docs/api/apps).
### Using your own domain
Add the domain to your project under **Domains** first. A domain whose DNS you
manage elsewhere has to be verified there before an app can serve it: until
then the app keeps the hostname but nothing is routed to it. Then, on the app's
**Network** tab, select **Change public URL** → **Custom domain** and choose it.
For a domain whose DNS you manage elsewhere, the panel shows a `CNAME` and a
`TXT` record to add; once they are in place, select **Verify now** and the
certificate is issued.
## Troubleshooting
**The build fails.** Open **Build logs**. With **Automatic**, check that the
**App directory** points at the folder containing your `package.json`,
`requirements.txt`, `go.mod` or equivalent. With **Dockerfile**, check that the
`Dockerfile` is in that same folder.
**The build succeeds but the deploy never becomes healthy.** Almost always a
port mismatch: the app is listening on a port other than the one it was given.
Make it listen on `$PORT`, or set **Port** to the port it uses. A release that
has not started serving within ten minutes is marked failed.
**A static site answers an unknown path with the wrong page.** Paths are served
the way static hosts usually do: `/about` finds `about.html` or `about/index.html`,
and a path that matches nothing gets your top-level `404.html` with a `404`
status if the site has one. A site without a `404.html` is treated as a
single-page app, and every unknown path gets `index.html` so its router can
take over. Add a `404.html` for a set of pages; leave it out for a single-page
app. A path ending in a file extension, such as a missing `.js` file, is always
a `404`.
## Next steps
* [Deploy a Next.js App](/docs/guides/deploy-nextjs)
* [App Configuration Files](/docs/guides/app-templates) — keep these settings in
your repository.
* [Connect to Redis](/docs/guides/redis) — add a cache or queue to your app.
Source: https://cosmoner.com/docs/guides/deploy-an-app
---
# Deploy from a Template
> Start from a ready-made starter app, or add a Deploy to Cosmoner button to your own repository's README so readers can run a copy in a few clicks.
## Overview
A template is a small, working app in a public GitHub repository, set up to deploy on Cosmoner as it is. You copy it to your own GitHub account, then deploy your copy like any other repository. From then on it is your code: change anything, and every push redeploys it.
Browse them on the [templates page](/en/templates).
| Template | Deploys as | Needs a database |
| ------------------- | ----------- | ------------------------------------------------ |
| Next.js | Web service | No |
| Astro | Static site | No |
| Hono API | Web service | No |
| Laravel | Web service | No, it uses SQLite, which resets on every deploy |
| Node and PostgreSQL | Web service | Yes, PostgreSQL |
| FastAPI | Web service | No |
| Django | Web service | Yes, PostgreSQL |
## Deploy a template
Select **Deploy** on a template. The page that opens walks you through two steps.
1. **Copy the template to your GitHub.** **Use this template** opens GitHub's own page for creating a repository from a template, on your account. Keep the suggested name if you like: the deploy step searches for it.
2. **Deploy it.** **Deploy to Cosmoner** takes you into the control panel. Log in or create an account first if you need to, and you come back to this step afterwards. Choose a project (or create one), and the [app wizard](/docs/guides/deploy-an-app) opens with GitHub selected and the repository search filled in with your copy's name.
If GitHub is not connected to the project yet, the wizard asks you to connect it first: install the Cosmoner GitHub App on the account that owns your copy. When the App is installed for selected repositories only, add your copy to it in GitHub's settings, or it will not be listed.
Once you select the repository, the wizard offers **Fill in from file**, which loads the template's build settings from its [configuration file](/docs/guides/app-templates). Choose a plan and launch.
### Templates that need a database
For a template that needs PostgreSQL, the wizard's last step already has **Add a PostgreSQL database** selected. Pick a plan, and the database is created together with the app:
* its connection string is stored as a [secret](/docs/guides/secrets) named `DATABASE_URL`, which the app reads;
* the app is linked to the database, so the resource graph shows them together;
* the summary shows the price of the app and of the database, and the database is billed exactly as one you create on its own.
The app's first deploy starts once the database is online, usually within a few minutes. See [Create an app with a database](/docs/guides/deploy-an-app#create-an-app-with-a-database) for what happens if the database cannot be created.
If your project already has a `DATABASE_URL` secret, **Fill in from file** links it and the option starts switched off. Switch it on to create a new database instead, under another secret name such as `SHOP_DATABASE_URL`, with the variable in your app left as `DATABASE_URL`.
Cosmoner does not create the repository for you. Copying it on GitHub keeps the repository in your account and under your control, and means the GitHub App never needs permission to create repositories.
## Add a deploy button to your README
Maintain a repository other people might want to run? Add the **Deploy to Cosmoner** button to its README. Readers who select it get the same guided page, for your repository: fork or copy it to their GitHub, then deploy their copy.
Replace `OWNER/REPO` with your repository.
Markdown:
```md
[](https://cosmoner.com/deploy?repo=https://github.com/OWNER/REPO)
```
HTML:
```html
```
The `repo` value must be the repository's GitHub address and nothing more: `https://github.com/OWNER/REPO`. A trailing slash or a `.git` suffix is accepted. A link to a branch, a file, or anything other than a GitHub repository shows an error page instead.
### Make your repository deploy well
The button deploys whatever the reader's copy contains, so a little preparation helps:
* Commit a [`.cosmoner/deployment.yaml`](/docs/guides/app-templates) describing the build and start commands, so **Fill in from file** sets the app up in one click.
* Listen on the port in the `PORT` environment variable. See [Ports](/docs/guides/deploy-an-app#ports).
* If the app needs a database or other secrets, say so in the README and reference them with `from_secret` in the configuration file, so the wizard links them. Readers without a database can add one on the wizard's last step, with **Add a PostgreSQL database**.
* Mark the repository as a template in its GitHub settings if you want readers to get a clean copy rather than a fork.
## Limits
* The repository must be on GitHub. GitLab and Bitbucket repositories deploy through the [app wizard](/docs/guides/deploy-an-app) as usual, without a button.
* Every deploy is of a repository the reader owns. Deploying straight from someone else's public repository is not supported.
Source: https://cosmoner.com/docs/guides/deploy-from-a-template
---
# Deploy a Next.js App
> Run a Next.js app on Cosmoner from your repository — server rendering, API routes and NEXT_PUBLIC_ variables included — or ship it as a static export.
## Overview
A Next.js app deploys as a **web service** with no configuration: Cosmoner
detects Next.js from your `package.json`, installs your dependencies,
runs `next build`, and starts the app with `next start`. Server components,
route handlers, middleware, image optimization and incremental static
regeneration all work, because it is the same Node.js server you run locally.
If your app is fully static — `output: 'export'` — you can deploy it as a
[static site](#static-export) instead, for a flat price.
This guide covers what is specific to Next.js. For the deploy flow itself, see
[Deploy an App from Git](/docs/guides/deploy-an-app).
## Deploy it as a web service
1. In your project, go to **Apps** → **New app** and choose a domain.
2. Under **Git Repository**, connect your provider and pick the repository and
branch. With a `next.config.js` or `next.config.ts`, the **Automatic**
strategy is labelled **Detected: Next.js**; any other config file builds
the same way without the label.
3. Leave the app type as **Web Service** and the strategy as **Automatic**.
**Build command** and **Run command** are detected from your `package.json`
— the `build` and `start` scripts — so leave them empty unless you use
different ones.
4. Leave **Port** empty. `next start` listens on the port in `PORT`, which the
app is given.
5. Add your environment variables (see below), then **Continue**, choose a plan
and **Launch**.
For a monorepo, set **App directory** to the Next.js app's folder, e.g.
`apps/web`.
### Choosing a plan
`next build` runs on the build machine, not on your plan, so the plan only has
to fit the running server. **Shared S** (1 GB of memory) is a comfortable start
for most apps; move up when **Metrics** shows memory close to the limit or
restarts.
## Environment variables
Next.js reads variables at two different times, and Cosmoner provides both:
| Variable | Read when | What to do |
| --------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_*` | At **build** time, and written into the JavaScript sent to the browser. | Add it to the app. It is available to `next build`. Changing it needs a new build — **Redeploy** after saving. |
| Everything else | At **run** time, on the server only. | Add it to the app. Changing it takes effect on the next deploy. |
Anything prefixed `NEXT_PUBLIC_` is public by design — it ends up in your
users' browsers. Never put a secret behind that prefix. Keep secrets such as
API keys and database URLs unprefixed, and link them from the vault with
**Link secret** so their values never sit in the app's settings — see
[Secrets](/docs/guides/secrets).
## Deploy it with your own Dockerfile
Use **Dockerfile** as the strategy when you want full control over the image,
for example with Next.js's `standalone` output. Two things differ from building
locally:
* **Build-time variables arrive as build arguments.** Declare each
`NEXT_PUBLIC_*` variable your build needs with `ARG` before `next build`,
or it will be empty in the bundle.
* **Listen on all interfaces.** The standalone server binds to `HOSTNAME`; set
it to `0.0.0.0`.
```dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG NEXT_PUBLIC_API_URL
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production HOSTNAME=0.0.0.0
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
```
With `output: 'standalone'` set in `next.config`. The server listens on `PORT`
when it is set, which it always is on Cosmoner; `EXPOSE 3000` is only used when
no port is stated anywhere else.
Variables you mark as secret are not passed as build arguments. They are
mounted for the build step instead, which keeps them out of the image layers:
read one with `RUN --mount=type=secret,id=NAME,env=NAME`.
## Static export
An app built with `output: 'export'` produces plain files and can be deployed as
a **Static Site**:
1. In `next.config`, set `output: 'export'`. Pages are found at their clean
URLs either way — `/about` serves `about.html` — so `trailingSlash` is your
choice.
2. In the wizard, choose **Static Site**.
3. Set **Build command** to `npm ci && npm run build` — a static build starts
from a clean Node.js environment, so it has to install dependencies itself.
4. Set **Output directory** to `out`.
A static export cannot use server-side features: route handlers, server
actions, middleware, or image optimization at request time. Dynamic routes need
`generateStaticParams` so every page exists as a file.
## Connecting a database or cache
Store the connection string as a secret and link it as an environment variable,
e.g. `DATABASE_URL` or `REDIS_URL`, then read it from `process.env` in server
code only. See [Connect to Redis](/docs/guides/redis) for a cache.
## Keep the settings in your repository
Commit a `.cosmoner/deployment.yaml` and the wizard fills itself in from it:
```yaml
# yaml-language-server: $schema=https://cosmoner.com/schemas/app.schema.json
services:
- name: web
type: service
autodeploy: true
envs:
- key: NEXT_PUBLIC_API_URL
value: https://api.example.com
- key: DATABASE_URL
from_secret: DATABASE_URL
```
See [App Configuration Files](/docs/guides/app-templates) for every field.
## Troubleshooting
**`NEXT_PUBLIC_` values are `undefined` in the browser.** The variable was added
after the last build, or — with a Dockerfile — is not declared with `ARG`.
Add it, then **Redeploy**.
**The deploy never becomes healthy with a Dockerfile.** The server is listening
on a port or interface other than the one it was given. Check that
`HOSTNAME=0.0.0.0` is set and that nothing overrides `PORT`.
**Pages of a static export return 404.** Check that **Output directory** is
`out` and that the page exists as a file in it — dynamic routes need
`generateStaticParams`.
Source: https://cosmoner.com/docs/guides/deploy-nextjs
---
# Deploy a Container Image
> Run any Docker image as an app — from your Cosmoner registry, Docker Hub or the GitHub container registry — and redeploy it from CI when you push a new tag.
## Overview
If you already build images, you can run one directly instead of having
Cosmoner build from your repository. Any language, any base image, any build
system: if it runs in a container and answers HTTP, it runs as an app.
An image app can pull from:
* **Your project registries** — a [Cosmoner container registry](/docs/guides/container-registry)
in the same project. No credentials needed, and pushes can redeploy the app
automatically.
* **Docker Hub**.
* **GitHub container registry** (`ghcr.io`).
Images from other registries cannot be deployed yet.
## 1. Push your image
For a Cosmoner registry, the repository page in the control panel shows the
exact `docker login`, `docker tag` and `docker push` commands for it. For Docker
Hub and the GitHub container registry, push as you normally would.
Build the image for `linux/amd64`. On an Apple Silicon Mac, that means
`docker build --platform linux/amd64 …` — an `arm64` image will not start.
## 2. Create the app
In your project, go to **Apps** → **New app**, choose a domain, then select the
**Container Image** tab.
* **From a project registry**, pick it under **Your registries**, then the image
and the **Tag**.
* **From Docker Hub or the GitHub container registry**, choose it under
**Container registry** and pick the image, or select **Enter an image
manually** and type its name. For a public image, leave the credentials
empty. For a private one, either connect the account, or enter a **Username**
and a read-only **Personal access token** — not your account password.
### Ports
Set **Internal container port** to the port your container listens on. That is
where traffic is sent, and the container is also given it in the `PORT`
environment variable. Both port fields default to `80`, which suits images
built on a web server such as nginx; an app server listening on `3000` or
`8000` needs it changed.
### Environment variables
Add, link and import variables the same way as for a git app — see
[Environment variables](/docs/guides/deploy-an-app#environment-variables).
They are set on the running container; nothing is rebuilt.
Then **Continue**, choose a plan and the number of instances (each is billed at the plan's price), and **Launch**.
## Deploying new versions
### On push, from a project registry
An app running an image from your own registry can redeploy itself when you
push. Under **Deploy on push**:
| Setting | What a push does |
| ---------------- | ------------------------------------------------------------------------------------------------------------------- |
| **Same tag** | Redeploys when the tag the app runs is pushed again — e.g. you always push `:latest` or `:production`. |
| **Newest image** | Deploys every push to the repository and switches the app to its tag — e.g. you tag each build with its commit SHA. |
| **Off** | Nothing. You deploy from the API, the CLI or CI. |
Change it later on the app's **General** tab, under **Deploys**.
### From CI, with the CLI
The [`cosmoner` CLI](/docs/sdk/cli) deploys a tag and waits until it is live,
which makes it a natural last step of a pipeline:
```bash
npx @cosmoner/cli deploy web --tag "$GIT_SHA"
```
It exits non-zero if the rollout fails, so the pipeline fails with it. Give it
an [API key](/docs/guides/api-keys) with `apps:read` and `apps:write` in
`COSMONER_API_KEY`. `--digest` deploys one exact image instead of a tag, and
with neither, the app's current tag is pulled again.
The CLI deploys images from Cosmoner registries. For Docker Hub and the GitHub
container registry, redeploy through the [Apps API](/docs/api/apps).
### Going back to an earlier version
Select **Roll back** on an earlier deployment in the **Deployments** section of
the app's **Overview** tab. The app goes back to the exact image that
deployment ran, pinned to its digest, so pushes to its tag no longer move it
until you deploy a tag again. Rolling back needs the image to still be in the
repository — deleting it from the registry removes that option.
From CI, deploying an earlier tag or digest does the same. Tagging images with
something that identifies the build, such as the commit SHA, rather than only
`latest`, keeps every version addressable.
## Troubleshooting
**The deploy never becomes healthy.** Check **Runtime logs**. The usual cause is
a port mismatch: **Internal container port** must be the port the process
listens on. Also check the process listens on `0.0.0.0`, not `127.0.0.1` —
a server bound to localhost cannot be reached from outside its container.
**`exec format error` in the logs.** The image was built for another CPU
architecture. Rebuild it with `--platform linux/amd64`.
**The image cannot be read.** For a private image, check the token has read
access to that repository, and that the name and tag are spelled exactly as in
the registry.
Source: https://cosmoner.com/docs/guides/deploy-container-image
---
# Pull Request Previews
> Give every pull request its own running copy of your app, at its own URL, rebuilt on each push and removed when the pull request closes.
## Overview
With previews on, each pull request against your app's deployed branch gets an
isolated copy of the app built from the pull request's code. Reviewers open a
link instead of checking out the branch.
* The preview is built when the pull request is opened, and again on every push
to it.
* The pull request shows a commit status and one comment with the preview's
link, kept up to date as it builds.
* The preview is removed when the pull request is merged or closed.
Previews are in beta, for apps built from a **GitHub** repository. They are
billed by the hour while they run: **$0.01 an hour for each preview**, or
**$0.005 an hour** for a static site. See [Billing](#billing).
## Before you start
* The app deploys from a GitHub repository. Previews are not built for apps that
run a container image, or for GitLab and Bitbucket repositories yet.
* The project has a saved payment method, and no outstanding balance. Previews
are billed on the project's subscription.
* The Cosmoner GitHub app has the permissions previews need. Previews read pull
requests and post commit statuses and comments, which the app could not do
before previews existed. GitHub asks an owner of the account the app is
installed on to approve the new permissions once. Until then pushes keep
deploying as usual, but no previews are built. The app's page tells you when
this approval is missing; approve it in the account's settings on GitHub,
under **Applications**.
## Turn previews on
Previews are off for every app until you turn them on. On the app's page, open
**Overview** and find **Pull request previews**, then turn on **Build a preview
of every pull request**.
Before anything changes, a dialog shows the hourly price and lists every
environment variable a preview will receive (see
[below](#environment-variables-and-secrets)). Check it, then select **Turn on**.
Selecting **Turn on** accepts the price. Nothing is charged until a preview
runs.
If the project has no payment method yet, you are asked to add one first, or,
when you are not the project's biller, to ask the biller to add one.
Turning previews on needs the developer, admin or owner role.
## What a preview gets
### Which pull requests
A pull request gets a preview when it targets the branch the app deploys from.
* Draft pull requests are skipped until they are marked ready for review.
* Pull requests from forks are skipped, because they run code from outside your
repository. The commit status says so.
### Its URL
Each preview is served at its own address on `cosmoner.app`, named after the app
and the pull request number, for example
`https://my-app-pr-12.cosmoner.app`. The link is in the pull request's comment
and on the app's page. HTTPS is set up for you.
Preview addresses are public, so anyone with the link can open them. They are
on a different domain from your app and from the control panel, so a preview
never sees your session.
### Size and limits
* A preview runs as one instance of the smallest size, whatever the app's own
plan. A static site's preview is served as a static site.
* At most 2 previews of an app, and 5 across the project, are live at once. A
pull request beyond that gets a status saying there is no room, and is built
by its next push once a preview has been removed.
* The platform also keeps a limited amount of room for previews. When it is
full, the pull request's status says so in the same way.
* A preview is removed after **72 hours without a push**. The next push to the
pull request builds it again.
### Environment variables and secrets
A preview builds and runs with:
| From the app | In the preview |
| ------------------------ | ---------------- |
| Plain values | Copied |
| Linked project variables | Copied |
| Secrets set on the app | **Never copied** |
| Linked project secrets | **Never copied** |
A pull request's code can read every variable it is given, while it builds and
while it runs. Secrets are left out so that a pull request can never see more
than its author could already push. A plain value is copied like any other, so
if you keep something sensitive as a plain value, such as a database URL, store
it as a [secret](/docs/guides/secrets) instead before you turn previews on.
Every preview also gets:
| Variable | Value |
| ---------------------- | ------------------------- |
| `COSMONER_PREVIEW` | `true` |
| `COSMONER_PR_NUMBER` | The pull request's number |
| `COSMONER_PREVIEW_URL` | The preview's address |
| `COSMONER_GIT_SHA` | The commit being built |
Use `COSMONER_PREVIEW` to change behaviour in previews, for example to skip
sending real emails.
Previews get no database of their own. An app whose code needs one should fall
back to something safe, such as an in-memory store, when `COSMONER_PREVIEW` is
set.
## Watching and removing previews
The app's page lists its live previews with the pull request number, the
status, the address, the commit and how long ago the preview was created.
| Status | Meaning |
| --------------------------------------- | ------------------------------------------------------------------------------------------------ |
| **Queued**, **Building**, **Deploying** | On its way up. |
| **Ready** | Serving the pull request's latest commit. |
| **Failed** | The last build or rollout failed. If an earlier build had succeeded, that version keeps serving. |
Select **Build log** next to a preview to read the log of its latest build. When
a build fails, the commit status on the pull request links straight to it on
the app's page, with the reason it failed.
To stop a preview early, select **Tear down** next to it. The pull request is
left open, and its next push builds the preview again. Tearing down a preview
needs the developer, admin or owner role.
Turning previews off removes every live preview of the app straight away, and
pull requests stop getting new ones, so the app's previews stop being billed.
## Billing
Previews are billed by the hour while they run, for each preview:
| Preview of | Price |
| ------------- | --------------- |
| An app | $0.01 per hour |
| A static site | $0.005 per hour |
* A preview is billed from its first successful deploy until it is removed. A
build that fails before the preview ever served costs nothing.
* Every preview counts on its own: two previews running for an hour are two
preview-hours.
* Nothing is charged while no preview runs. Turning previews off, deleting the
app, or removing the GitHub connection from the project removes the app's
previews, which stops the charge. Reconnecting GitHub does not turn previews
back on.
* The app's page shows how many preview-hours its previews have run so far in
the current billing period. On your invoice and on the project's **Billing**
page, preview-hours appear as **Pull request previews** and **Static site
previews**.
* Previews are never turned on for you. Apps that had previews on before they
became paid have them turned off, and they stay off until you turn them on
again.
The hourly price and this period's preview-hours are also returned as `price`
and `hoursThisPeriod` by the preview settings request below.
### From the repository's configuration file
A service in `.cosmoner/deployment.yaml` can ask for previews with
`previews.enabled` (see [App configuration files](/docs/guides/app-templates)). When
you deploy from the file, the deploy wizard offers previews already selected,
with their price, and turns them on once the app is created. After that, the
file never turns previews on or off: a different value in the file shows as a
difference on the app's page, and you change previews with the switch.
## Using the API
Previews can be managed with an API key that has the `apps` scope:
| Request | What it does | Needs |
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| `GET /v1/projects/{projectId}/apps/{appId}/previews/settings` | Whether previews are on, whether they can be, what they cost per hour, this period's preview-hours, and which variables a preview receives | `apps:read` |
| `PATCH /v1/projects/{projectId}/apps/{appId}/previews/settings` | Turns previews on or off, with `{ "enabled": true }`. Turning them on accepts the hourly price; a project that cannot be charged gets a `402` | `apps:write`, developer role or above |
| `GET /v1/projects/{projectId}/apps/{appId}/previews` | Lists the app's previews | `apps:read` |
| `GET /v1/projects/{projectId}/apps/{appId}/previews/{previewId}` | Fetches one preview | `apps:read` |
| `GET /v1/projects/{projectId}/apps/{appId}/previews/{previewId}/build-log` | Reads the log of a preview's latest build | `apps:read` |
| `DELETE /v1/projects/{projectId}/apps/{appId}/previews/{previewId}` | Tears one preview down | `apps:write`, developer role or above |
See [Apps](/docs/api/apps) in the API reference for the request and response
shapes.
## Troubleshooting
**A pull request got no preview.** Check that previews are on for the app, that
the pull request targets the app's deployed branch, that it is not a draft and
not from a fork, and that the GitHub permissions are approved. The commit
status on the pull request says why when a preview was skipped or there was no
room for it.
**The preview fails to build or start.** Follow the link in the failed commit
status, or select **Build log** next to the preview on the app's page. A missing secret is the usual cause: secrets are
never copied, so an app that refuses to start without one needs a fallback when
`COSMONER_PREVIEW` is set.
Source: https://cosmoner.com/docs/guides/preview-deployments
---
# Passkeys
> Sign in with Touch ID, Face ID, Windows Hello, or a security key — no password and no code, and nothing a phishing site can capture.
A passkey signs you in with your fingerprint, your face, your device PIN, or a
hardware security key. It replaces both the password and the two-factor code in
a single prompt.
That is not a shortcut. A passkey is a keypair: the private half is created
inside your device's secure hardware and never leaves it, and Cosmoner only ever
stores the public half. There is nothing in our database that could sign in as
you, and nothing for a fake login page to capture — your browser will not offer
a Cosmoner passkey to any site that is not Cosmoner, no matter how convincing it
looks.
It also already counts as two factors: something you have (the device) and
something you are (the fingerprint or face). That is why a passkey is accepted
as a complete sign-in rather than as a step after your password.
## Add one
Go to **Account → Security → Passkeys** and choose **Add a passkey**.
1. **Name it** — "MacBook Pro", "Work phone", "YubiKey". Only you see this; it
is stored with your account, not on the device, and you can rename it later.
2. **Confirm on your device.** Your browser or operating system takes over and
asks for your fingerprint, your face, or your PIN.
That is the whole setup. There is no code to enter and nothing to save.
You can register as many as you like, and you should register at least two —
see [Not losing access](#not-losing-access) below.
### What you can use
* **Built-in biometrics** — Touch ID and Face ID on Apple devices, Windows
Hello, Android's fingerprint or face unlock.
* **A password manager** — 1Password, Bitwarden, iCloud Keychain, Google
Password Manager. These sync the passkey across your devices.
* **A hardware security key** — a YubiKey or similar, over USB, NFC, or
Bluetooth.
Both kinds are offered when you add one, so you are not locked into a single
sort of device.
## Signing in
On the login page, choose **Sign in with a passkey**. Your device prompts you,
and you are in.
Often you will not need the button at all: tap the email field and any passkey
registered for Cosmoner appears in the autofill list, the same way a saved
password does. Picking it signs you in directly.
Passkeys work in the internal admin console too, for accounts that have access
to it — the passkey belongs to your account, not to one app.
## Not losing access
This is the part worth reading twice.
**A synced passkey follows you.** If it lives in iCloud Keychain, Google
Password Manager, or a password manager like 1Password, it is available on your
other devices automatically, and a broken laptop costs you nothing.
**A device-bound passkey does not.** Windows Hello passkeys and hardware
security keys stay on the thing that created them. If that is your only passkey
and the device is lost, that route into your account is gone with it.
So: **register a second passkey** — a phone, or a security key you keep
somewhere safe. The security page shows a warning when your only passkey is one
that cannot sync.
Your password and, if you use it, [two-factor
authentication](/docs/guides/two-factor-authentication) keep working
throughout. Adding a passkey does not disable either, and they remain a way back
in.
## Managing them
**Account → Security → Passkeys** lists every passkey on the account, when it
was added, and whether it is backed up to a cloud keychain.
* **Rename** changes the label only; the passkey itself is untouched.
* **Remove** stops that device signing in. You can register it again whenever
you like.
Removing a passkey is the right move whenever a device is lost, sold, or out of
your hands. Unlike a password, there is no need to change anything else — the
other passkeys on the account are unaffected, because each one is its own
independent key.
## Browser support
Passkeys need a reasonably current Safari, Chrome, Edge, or Firefox and a
device with a fingerprint reader, a camera, or a PIN. Where they are not
supported, the security page says so, and password sign-in is unchanged.
Source: https://cosmoner.com/docs/guides/passkeys
---
# Two-Factor Authentication
> Add a second step to sign-in so a stolen password is not enough to reach your servers, domains, and secrets on its own.
Two-factor authentication (2FA) asks for a six-digit code from an authenticator
app after your password. Your Cosmoner account controls servers, DNS, and
secrets, so a password that leaks — reused on another site, phished, or pulled
from a breach dump — is otherwise all someone needs.
A [passkey](/docs/guides/passkeys) — Touch ID, Face ID, Windows Hello, or a
security key — covers the same ground in a single prompt and cannot be phished,
since there is no code to read out and no password to type. If your devices
support one, start there. The two work side by side, and having both is the
belt-and-braces option.
## Turn it on
Go to **Account → Security** and choose **Set up**.
1. **Confirm your password.** This stops someone at an unlocked laptop from
adding a factor only they hold.
2. **Scan the QR code** with any TOTP app — 1Password, Bitwarden, Google
Authenticator, Authy. If you are enrolling a password manager on the same
screen, use the **enter this key manually** option instead.
3. **Enter the six-digit code** the app shows.
2FA switches on only after that code is accepted, so a mis-scanned QR cannot
lock you out — an abandoned setup leaves your account exactly as it was.
## Save your recovery codes
Setup ends with ten single-use recovery codes. They are shown **once**: we store
them encrypted and have no way to read them back to you.
Save them somewhere other than the phone or laptop running your authenticator
app. If you lose that device, these codes are the only way back into the
account. Each one works a single time, and using one does not disable 2FA.
Lost them, or used most of them? **Account → Security → New recovery codes**
issues a fresh set and immediately invalidates the old one.
## Signing in
After your password you are asked for a code. If you cannot reach your
authenticator app, choose **Use a recovery code instead** and enter one of the
codes you saved.
Too many wrong codes in a row temporarily locks the second-factor step on the
account. Wait a few minutes and try again — it clears on its own, and on the
first code that is accepted.
## Trusted devices
Ticking **Trust this device for 30 days** at the code prompt skips the code on
that browser until the 30 days are up. It is per browser and per device, so
your laptop being trusted says nothing about your phone.
Two things are worth knowing:
* **Signing out does not untrust a device.** The trust outlives the session by
design.
* **Revoking is the lever.** **Account → Security → Trusted devices → Revoke
all** makes every device — including the one you are on — ask for a code
again. Do this whenever a device is lost, sold, or out of your hands.
## Turning it off
**Account → Security → Turn off**, confirmed with your password. Your recovery
codes are discarded and your password alone gets you in again. Turning 2FA back
on later starts a fresh setup with a new QR code and new recovery codes.
## Sign-in methods
**Account → Security → Sign-in methods** lists every way into your account: a
password, a connected GitHub identity, or both. Connecting GitHub is what makes
a mismatched email harmless — the identity is joined to the account you are
already signed in to, so a work address here and a personal one on GitHub still
resolve to one account.
GitHub has to have verified the primary email on the GitHub account you
connect, and **Continue with GitHub** only joins an existing Cosmoner account
with the same address when GitHub has verified it. If connecting is refused,
verify your primary email in GitHub's email settings and try again.
You cannot disconnect your only remaining method. If GitHub is all you have,
set a password first via the password reset link.
### GitHub sign-in and 2FA
The code prompt applies to email-and-password sign-in. **Continue with GitHub**
is governed by the second factor on your *GitHub* account instead, so if both
are enabled on your Cosmoner account, turn 2FA on at GitHub too — otherwise
whoever controls the linked GitHub account walks around the code prompt. The
security page shows a warning when this applies to you.
Source: https://cosmoner.com/docs/guides/two-factor-authentication
---
# Account Security
> Active sessions, changing or resetting your password, and the emails we send when something about your account's protections changes.
Everything on this page lives at **Account → Security** in the control panel,
apart from resetting a forgotten password, which starts on the sign-in page.
Two-factor authentication and passkeys have guides of their own —
[two-factor authentication](/docs/guides/two-factor-authentication) and
[passkeys](/docs/guides/passkeys) — this one covers the rest.
## Active sessions
**Active sessions** lists every browser currently signed in to your account,
with the device, IP address, and when it signed in. The session you are reading
this from is pinned to the top and labelled.
This is the part people miss, so it is worth stating plainly:
**Changing your password does not end sessions that are already signed in.**
Neither does turning on two-factor authentication, and neither does revoking
trusted devices. All three govern the *next* sign-in.
If you think someone else is in your account, **Sign out everywhere else** is
the control that removes them. It ends every session except the one you are
using. You can also sign out a single session from its row, which is what you
want for a laptop you no longer have rather than a suspected intruder.
## Changing your password
**Change password** changes it in place, without the emailed reset round-trip.
You will need your current password — if you have forgotten it, use the reset
link instead and we will email you one.
**Sign out everywhere else** is ticked by default here, and we would suggest
leaving it. A password change prompted by a suspected leak accomplishes very
little if the other person's session survives it.
### If your password is rejected
We check new passwords against public breach corpora. A rejection does not mean
your password is too short or too simple — it means that exact password appears
in a known breach dump, which puts it near the top of the list an attacker
tries first. Almost always this is a password reused from a site that was
breached years ago.
The check is anonymised: only a short, partial fingerprint of the password is
used to query the breach dataset. Your password itself never leaves our
servers, and neither does anything that could be used to reconstruct it.
## Forgotten your password
Choose **Forgot password?** on the sign-in page and enter your email address.
If an account uses that address, we email it a link to choose a new password.
The page answers the same way whether or not an account exists — we never
confirm which addresses have one — so if no email arrives, check your spam
folder and the spelling of the address before asking for another. Requests are
rate-limited: ask for several links in a row and you will be told to wait
before the next one.
The link expires after a short time and works once; if it has expired, request
a new one. Completing the reset also confirms your email address, so it gets
you in even if the original verification email never arrived. If you signed up
with GitHub and never had a password, this is how you set one.
Like a password change, a reset does not end sessions that are already signed
in. If you are resetting because you think someone else has your password, sign
in with the new one and choose **Sign out everywhere else**.
## Security emails
We email you when something changes that affects who can reach your account:
* Two-factor authentication turned on or off
* New recovery codes issued
* Your password changed
* A sign-in method added or removed
* Trusted devices revoked
These are deliberately hard to turn off, because they are how an account
takeover gets noticed. An attacker who has your password can disable your
second factor — the email is what tells you.
**If one of these arrives and it wasn't you**, someone else has your password.
Change it, then sign out every other session — both from **Account → Security**
— and [contact support](/controlpanel/support) if anything still looks wrong.
We will never email you a recovery code, a two-factor secret, or a link that
asks for your password. Anything that does is not from us.
Source: https://cosmoner.com/docs/guides/account-security
---
# Email notifications
> Which emails Cosmoner sends, how to turn product updates and the newsletter on or off, and the notifications in the control panel.
Cosmoner sends some emails to the address on your account because they are
part of running it, and others only while you want them.
## Always sent
These are part of running your account and cannot be switched off:
* **Billing and invoices** — invoices, failed payments, refunds, and changes to
who pays for a project.
* **Security** — sign-in methods added or removed, two-factor authentication
turned on or off, and password changes.
* **Resource alerts** — failed deployments, storage limits, paused webhook
endpoints, and anything else about your resources that needs attention.
* **Support and invitations** — replies to your support tickets and
invitations to projects.
## Product updates
When we publish an entry on the [changelog](/changelog), we email it to every
account that has not opted out. These go out a few times a month at most, and
each one carries the full entry, so you can read it without leaving your inbox.
You can turn them off in two ways:
* **From the email.** Every product-update email ends with an *Unsubscribe*
link. It opens a page that confirms before opting you out, so a link opened
by a mail client's preview does not unsubscribe you by accident. Mail clients
that offer their own unsubscribe button use the same mechanism.
* **From your account.** Go to **Account → Email notifications** and switch
*Product updates* off. The same page turns them back on.
Opting out applies to your account, not to a project: it stops product-update
emails to your address whichever projects you belong to. Notifications inside
the control panel are not affected.
## Newsletter
The [newsletter](/newsletter) is broader than the changelog: guides, stories and
news from the team. It is off until you turn it on.
* **Turn it on or off** under **Account → Email notifications**, with the
*Newsletter* switch.
* **From the email.** Every issue ends with an *Unsubscribe* link that turns the
newsletter off and leaves product updates as they are.
You can also sign up on the [newsletter page](/newsletter) without an account,
for example with a different address. That sign-up is separate from your
account's switch: confirm it from the email we send, and leave it with the
*Unsubscribe* link in any issue. If the address you sign up with is also on an
account that has the newsletter on, you get one copy, not two.
## Notifications in the control panel
The control panel keeps its own list of notifications alongside the emails. The
bell at the top of the control panel shows a dot while any are unread, and
opens the **Notifications** page, newest first.
Notifications belong to your account rather than to a project, so the list
covers every project you are a member of. Each one says what produced it — a
received payment, a registered domain, a reply to a support ticket — and links to
the page it is about where there is one.
* **Filters.** Narrow the list by event, by severity (*Info*, *Warning*,
*Error* or *Success*), to read or unread only, and to the last 7 days, 30
days or 12 months. Without filters the page shows your most recent
notifications.
* **Reading one.** Opening a notification marks it as read.
* **Mark all as read** marks every unread notification, including any the
current filters are hiding.
Some older notifications do not record which event produced them. They appear
in the unfiltered list but not under an event filter.
Source: https://cosmoner.com/docs/guides/email-notifications
---
# App Configuration Files
> Commit a .cosmoner/deployment.yaml to your repository to describe your app's build, runtime, and environment configuration — the deploy wizard fills its form in from it, and your editor validates it as you write.
## Overview
When you deploy an app from a git repository, you normally configure the source directory, build settings, ports, and environment variables by hand in the deploy wizard. A configuration file moves that into your repository: commit a `.cosmoner/deployment.yaml`, and the wizard reads it as soon as you select the repository and offers to fill the form in from it.
Nothing is applied until you ask for it. When the file is found, the wizard shows **Fill in from file** — press it and every setting the file defines is filled in, and every setting stays editable afterwards. Deploying by hand from a repository that has a file is always still an option.
A file can describe **one or more services**. Each deploy sets up one service — when a file defines several, you pick which one to fill the form from, and run the wizard again for the others.
To add a **Deploy to Cosmoner** button to a repository's README, or to start from one of our starter templates, see [Deploy from a Template](/docs/guides/deploy-from-a-template).
Configuration files apply to apps deployed from a git repository. Apps deployed from a container image are configured in the wizard.
## File location
The file is looked up on the branch you selected, in this order:
1. `.cosmoner/deployment.yaml`
2. `.cosmoner/deployment.yml`
3. `deployment.yaml`
4. `deployment.yml`
`.cosmoner/deployment.yaml` is the recommended location. If you'd rather not add a directory, put a `deployment.yaml` (or `deployment.yml`) at the root of the repository instead. The first file found is used, so a file in `.cosmoner/` takes precedence over one at the root.
### Paths in the file
Paths are relative to the **repository root**, wherever the file itself lives. `source_dir: .` (or leaving `source_dir` out) means the root of the repository, even when the file is in `.cosmoner/`. For a monorepo, point `source_dir` at the service's directory, e.g. `apps/api`.
The build runs from `source_dir`. That directory is what gets built, and for `docker` builds the `Dockerfile` is read from there.
## Editor support
The format is published as a JSON Schema at [`https://cosmoner.com/schemas/app.schema.json`](https://cosmoner.com/schemas/app.schema.json). Point your editor at it and you get completion for every field, a description on hover, and an error on a mistyped key — before you push.
In VS Code, Zed, Neovim, and anything else running the YAML language server, add a comment to the top of the file:
```yaml
# yaml-language-server: $schema=https://cosmoner.com/schemas/app.schema.json
services:
- name: api
```
Or set it once for every app file, in VS Code's `settings.json`:
```json
{
"yaml.schemas": {
"https://cosmoner.com/schemas/app.schema.json": [".cosmoner/deployment.yaml", ".cosmoner/deployment.yml"]
}
}
```
JetBrains IDEs read the same URL from a `$schema` key in the document itself:
```yaml
$schema: https://cosmoner.com/schemas/app.schema.json
services:
- name: api
```
## Example
```yaml
# yaml-language-server: $schema=https://cosmoner.com/schemas/app.schema.json
version: 1
name: my-platform
region: se-sto
environment: production
services:
- name: api
type: service
source_dir: apps/api
build:
strategy: docker
command: bun run build
run_command: bun start
port: 3000 # optional — see Ports
instance_size: shared-s
instances: 2
autodeploy: true
previews:
enabled: true # offered in the wizard with its price
envs:
- key: NODE_ENV
value: production
- key: API_URL
from_variable: PUBLIC_API_URL # a project variable
- key: DATABASE_URL
from_secret: DATABASE_URL # a stored secret, resolved by name
- key: STRIPE_KEY
secret: true # you are asked for the value in the wizard
- name: web
type: static
source_dir: apps/web
build:
command: bun run build
output_dir: dist
```
## Environment variables, secrets, and variables
A variable takes its value from exactly one of four places. Setting two is an error, so the file always says plainly where a value comes from.
| Form | What it does |
| --------------------- | --------------------------------------------------------------------------------------------------------- |
| `value: production` | A literal, committed with the file. Non-sensitive configuration only. |
| `from_variable: NAME` | Links a [project variable](/docs/guides/secrets). The stored value is read when the app is built and run. |
| `from_secret: NAME` | Links a [project secret](/docs/guides/secrets). The value stays in the vault. |
| `secret: true` | Marks the variable sensitive without giving it a value — the wizard asks for it and stores it masked. |
**Never commit a secret value.** A file that sets both `value` and `secret: true` is rejected, and nothing from it is applied: the value is in your git history, and the fix is to rotate it, not to deploy it.
### How a reference is resolved
`from_secret` and `from_variable` name an entry in your project, and the name is resolved against your project when the wizard reads the file — the file itself never carries a value, and a linked secret's value is not shown in the wizard, returned by the API, or written into your app's configuration. It is read from the vault when the app deploys, and the link keeps working after you rotate the secret.
The top-level `environment` field says which environment the names resolve against, defaulting to `default`. An entry in that environment wins over one of the same name in `default`.
If a name has no matching entry, the wizard says so and fills the row in empty — create the secret or variable and fill the form in again, or type the value by hand.
## Ports
Most services need no port setting. Your app is given its port in the `PORT` environment variable, so a service that listens on `$PORT` is reachable without anything in the file.
When your service listens on a fixed port instead, the port is chosen in this order:
1. `port`, when the file or the app's settings state one.
2. The port in your Dockerfile's `EXPOSE`, for `docker` builds.
3. `8080`.
`PORT` is always set to the chosen port, unless you set `PORT` yourself in `envs` — in that case, set `port` to the same value. Static sites are served by the platform and take no port.
`http_port` and `internal_port` from earlier versions of this format are still read, as `port`, but are deprecated. A file that sets `port` alongside either is rejected.
## Reference
### Top level
| Field | Type | Description |
| ------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | number | Version of the file format. Optional — a file without it is read as version `1`, the only version there is. |
| `name` | string | Optional display name for the deployment described by the file. |
| `region` | string | Region slug to preselect, e.g. `se-sto` (Stockholm). Ignored when it is not a region you can deploy to — see [Sizes and regions](#sizes-and-regions). |
| `environment` | `default` \| `development` \| `staging` \| `production` | Which environment `from_variable` and `from_secret` resolve against. Defaults to `default`. |
| `services` | list | One or more services (max 10). Required. |
| `$schema` | string | Optional, for editors that read the schema URL from the document. Ignored when the file is applied. |
### Service
| Field | Type | Description |
| ------------------ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | string | Required. Lowercase letters, numbers, and hyphens (max 32 chars). Must be unique within the file. |
| `type` | `service` \| `static` | Defaults to `service`. `static` builds once and is served as files. |
| `source_dir` | string | Directory the service is built from, relative to the repository root — `.` is the root, even when the file is in `.cosmoner/`. Defaults to the root. See [Paths in the file](#paths-in-the-file). |
| `build.strategy` | `nixpacks` \| `docker` | `docker` builds the Dockerfile in `source_dir`; `nixpacks` detects the language. Defaults to auto-detection. |
| `build.command` | string | Build command. Leave unset to auto-detect. Ignored by `docker` builds, which run the Dockerfile. |
| `build.output_dir` | string | Directory the build writes the site into. Static sites only. |
| `run_command` | string | Command that starts the service. Web services only. |
| `port` | number | Port the process listens on. Optional — see [Ports](#ports). Not allowed on static sites. |
| `http_port` | number | Deprecated — use `port`. |
| `internal_port` | number | Deprecated — use `port`. |
| `instance_size` | string | Size slug to preselect, e.g. `shared-s`. Ignored when it is not a size you can buy — see [Sizes and regions](#sizes-and-regions). |
| `instances` | number | Instance count (1–10). |
| `autodeploy` | boolean | Deploy automatically on push to the selected branch. |
| `previews.enabled` | boolean | Build a preview of every pull request. GitHub repositories only. Previews are billed by the hour while they run, so this preselects the option in the deploy wizard, where the price is shown, and is never applied by a commit. See [Pull request previews](/docs/guides/preview-deployments). |
| `envs` | list | Environment variables (max 100). Keys must be unique within a service. |
### Environment variable
| Field | Type | Description |
| --------------- | ------- | ---------------------------------------------------------------------------------- |
| `key` | string | Required. Letters, numbers, and underscores, starting with a letter or underscore. |
| `value` | string | A literal value. |
| `from_variable` | string | Name of a project variable to link. |
| `from_secret` | string | Name of a stored secret to link. |
| `secret` | boolean | Marks the variable sensitive; the wizard asks for the value. |
## Sizes and regions
`instance_size` and `region` are the only two settings the file preselects on a later step of the wizard rather than filling into the form in front of you, and both take a slug rather than the name shown on the size and region pickers. A slug you cannot deploy to is ignored, silently — a file is not a way to buy a size that is not on sale.
If you are not sure of the slug, leave the field out: the wizard picks its default and you choose on the configure step. An existing app's slug is its `instanceSize`, returned when you read the app through the [Apps API](/docs/api/apps).
## Versioning
`version` states which revision of this format the file is written for. Leave it out and the file is read as version `1`; state a version this platform does not read and the file is refused outright, with an error saying so, rather than being half-understood by an older reader.
It only moves when a change would make an older reader wrong about a file. Adding a new optional field is not that — an older reader ignores what it does not recognise — so a file can use new fields without changing its version.
## Validation
If the file exists but is invalid — malformed YAML, a missing required field, duplicate service names, a variable that names two sources — the wizard shows the validation error and offers no fill. Nothing is applied from an invalid file, so a typo can never half-configure an app.
The wizard validates exactly what your editor does, from the same schema, with one difference: a key the platform does not recognise is an error in your editor (it is almost always a typo) and is ignored when the file is applied, so a file written for a newer field still fills in everything else.
## After the app exists
An app created from a file keeps watching it. Its **Configuration file** section, on the app's settings page, compares the two and lists any setting where they disagree — saying for each one whether the repository changed it or you did, since the file was last applied.
By default it reports and nothing more — reconciling the two is your call. **Apply the file** writes every setting it states onto the app in one go, and the section's mode control decides what happens without you:
| Mode | What the file does |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Compare only** | Differences are listed. Nothing is written. This is where an app starts. |
| **Apply on every push** | The file is also written to the app on every push to the deployed branch, before the build. Settings you change in the panel are overwritten on the next push — the file is the source of truth in this mode, which is the point of it. It needs **Auto-deploy on push** turned on: the file is applied when a push is seen, and pushes are only seen for an app that deploys on push. Turning auto-deploy off later stops the file from being applied until it is back on. |
| **Ignore the file** | It is not read at all. |
Three settings are never written from a file, in any mode, because they change what you pay. `instance_size` and `instances` move your app between plans, so a commit landing never resizes an app: the file asking for a different size shows up as skipped, and you change it on the app's plan page where the price is shown. `previews.enabled` starts or stops billing for pull request previews, so a file asking for a different setting shows up as skipped too, and you turn previews on or off with the switch on the app's page.
Applying pushes the settings to the app without rebuilding it: an environment variable or a port takes effect on the next deploy, and a build command applies to the next build. A push applies and builds in that order, so a commit that changes both its code and its build command gets both.
A file that references a secret or variable your project does not have is never applied — create the entry first. Applying it would deploy the app without that value, and the failure would surface later as a crash with nothing pointing at the cause.
The section also speaks up when the repository stops describing the app at all — the file deleted, no longer valid, or no longer defining the service the app was created from. In each case the app carries on with the settings it has.
## What a file does not do
* It fills the deploy wizard in, is compared against the app afterwards, and reconfigures it only when you apply it or set the app to apply on push.
* It never sets a value you have not stored with us. A linked secret is a link; a `secret: true` variable is a prompt.
Source: https://cosmoner.com/docs/guides/app-templates
---
# Web Hosting
> How web hosting is laid out, how to run several websites and domains on it, and how to reach your files.
## Overview
Web hosting runs PHP websites on Apache, the way a classic LAMP host does. You
create one hosting, and it can serve many websites at once: its own address,
the domains you attach, and subdomains of those domains. They share the tier's
storage, PHP workers and memory.
Every hosting includes:
* **Several websites, domains and subdomains** served from folders you control
* **SFTP and SSH access** on every tier
* **A file manager** in the control panel for quick edits and uploads
* **Unmetered bandwidth**
## Tiers
| Tier | Storage | PHP workers | PHP memory per request | Domains |
| ----------- | ------- | ----------- | ---------------------- | ------- |
| **Starter** | 10 GB | 2 | 128 MB | 1 |
| **Growth** | 25 GB | 4 | 256 MB | 5 |
| **Scale** | 50 GB | 8 | 256 MB | 25 |
The wizard shows the monthly price beside each tier. Bandwidth is not metered
on any tier. Every rate, add-ons included, is on
[Web Hosting Pricing](/docs/pricing/web-hosting).
### Extra storage
If a tier's disk is not enough, add storage on top of it in 10 GB blocks, at
USD 1 per block per month. You can add up to 50 GB for now. Larger sizes are
on the way.
Extra storage is chosen when the hosting is created.
## Backups
Every hosting is backed up once a night, and each night only what changed
since the night before is stored. Everything in your hosting's folder is
included except `tmp/`, where PHP keeps sessions and uploads in progress. If
your hosting has MySQL databases, each one is copied into the same nightly
backup, so a backup's files and databases are from the same moment.
How long backups are kept depends on the retention you choose:
| Retention | Price |
| ----------- | ------------------------------------ |
| **7 days** | Included |
| **30 days** | USD 0.50 per 10 GB of disk per month |
| **90 days** | USD 1.00 per 10 GB of disk per month |
The price counts the hosting's whole disk, the tier's storage plus any extra
storage, in started blocks of 10 GB. A Growth hosting has 25 GB, so it counts
as three blocks.
Choose a retention when you create the hosting, or change it at any time on
the hosting's **Backups** tab. A longer retention is charged straight away for
the rest of the billing period. If you shorten it, backups older than the new
retention are deleted the next night.
The Backups tab also lists the recent nightly backups and how much space they
use.
### Restoring a backup
Every backup still within your retention has a **Restore** button on the
Backups tab. If your hosting has databases, you choose what to restore: its
files, any of its databases, or both.
* **Files** go back exactly as they were that night. Files changed since are
overwritten, and files added since are deleted. `tmp/` is left as it is.
* **A database** is emptied and loaded from that night's copy, so everything
written to it since is gone. Databases you do not choose are left as they
are. A backup offers the databases your hosting has now that it holds a
copy of.
A restore cannot be undone, so download anything you want to keep first, or
export a database you may need again.
Your hosting stays online while it restores, and the tab shows when it is
done. A large hosting can take a few minutes. Only one restore runs at a time,
and none can start while that night's backup is running.
If you only need one file or folder back, contact support and we restore it
into a new folder beside your site instead.
When you delete a hosting, its backups are kept for its retention period and
then removed. Billing for them stops when the hosting is deleted.
## How your files are laid out
Your hosting has one home folder. Each address it answers on gets a folder
named after it, with the public files in `public_html` inside:
```
/var/www//
├── /
│ └── public_html/ # your hosting's own address
├── example.com/
│ ├── public_html/ # example.com, once attached
│ └── shop/ # shop.example.com, once attached
└── tmp/ # PHP sessions and uploads
```
Only a folder that an address points at is served. Anything next to
`public_html`, such as configuration files or backups, stays private until
you decide otherwise. A folder is never served because of its name alone.
### Your hosting's address
Every hosting is given an address on `cosmoner.com`, shown on its page in the
control panel. It serves the `public_html` inside the folder named after it.
### Domains
A hosting answers on the domains of its project. Add the domain to the
project first, on the project's **Domains** page in the control panel: register
it, move it here, or add one you keep elsewhere and verify it. Only a verified
domain can be attached, so no other project can claim a name that is yours.
Then choose **Attach domain** on the hosting's **Domains** tab and pick the
domain. By default it is served from `/public_html`, and the folder is
created for you. To serve a folder you already have, choose **A folder already
on the site** and browse to it. A folder that another domain already serves is
labelled with that domain.
What happens with DNS depends on who runs the domain's DNS:
* **Registered or moved to Cosmoner:** the DNS records are added for you. The
domain goes live as soon as its certificate is issued, usually within a few
minutes. If the domain already has a record at that name, the attach stops
and says which one, so nothing you set up is overwritten.
* **Managed elsewhere:** the panel shows the records to add at your DNS
provider. The domain goes live once they are in place.
### Subdomains
Subdomains are available on domains you attach, not on your hosting's own
`cosmoner.com` address.
A subdomain of your own domain, such as `shop.example.com`, is attached like
any other domain: pick `example.com` and type `shop` in front of it. It counts
as one domain. When
`example.com` is already attached, it defaults to a folder inside it, beside
`public_html`: `example.com/shop`.
A folder you already have can be turned into a subdomain from the file
manager: open its menu and choose **Serve as domain**. The attach page opens
with that folder chosen and the subdomain its name implies filled in.
### Redirects and parked domains
A domain does not have to serve a folder. When you attach it, or later from
its **Change** button, choose one of:
* **Website** serves a folder, as above.
* **Redirect** sends every visitor to another address and keeps the path they
asked for, so `old.example.com/about` lands on `https://example.com/about`.
Pick 301 for a permanent move or 302 for a temporary one. The target is a
full address without a query string.
* **Parked** shows a placeholder page. Use it to hold a domain, with its DNS
and certificate already in place, until the website is ready.
Switching between them keeps the domain attached, so there are no DNS records
to change. Redirects and parked domains count against the tier like any other
attached domain.
## Uploading files
### SFTP
Connect with any SFTP client, such as FileZilla, Cyberduck or WinSCP. The host,
port and username are on the hosting's page, and the password can be revealed
there. The host and port are the same for every hosting: `sftp.cosmoner.com`
on port `2022`. Your username is what picks your hosting.
SFTP does not run on port 22. Use port 2022: connecting on port 22 blocks your
address for a day.
Five failed logins from one address block that address for an hour.
Plain FTP and FTPS are not offered.
### SSH
Every tier also includes a shell on the same host, port and credentials. It
has `git`, and `composer` and `wp-cli` for installing PHP packages and managing
WordPress from the command line. `composer` and `wp-cli` come with the current
site image: a hosting created before they were added gets them with its next
update, see [Updates and rollback](#updates-and-rollback).
### File manager
The **Files** tab in the control panel lists, uploads, downloads, renames and
deletes files, and opens text files in an editor. Each transfer is limited to
64 MB. Members with the viewer role see the file list but cannot open or
download files, since a site's files often hold its passwords.
### Console
The **Console** tab opens the same shell as SSH in your browser, without an SSH
client or a password. It runs as your hosting's own user and starts in its home
folder. Members with the viewer role don't see it, and the session ends when
you leave the tab.
## How PHP runs
Each hosting runs in its own isolated container, so other customers cannot
reach your files or processes.
* **PHP version.** PHP 8.4 or 8.3, chosen when you create the hosting and
changed at any time from its overview, see [PHP versions](#php-versions).
All websites on the hosting use the same version.
* **PHP workers** are how many PHP requests run at the same time. Further
requests wait for a free worker. If your sites slow down under traffic, a
higher tier adds workers.
* **Memory per request** is PHP's `memory_limit`, set by the tier. It cannot
be raised from `.htaccess`.
* **Defaults.** Uploads and form posts up to 64 MB, a 120 second execution
limit, and OPcache enabled.
* **`.htaccess`** works for rewrites, redirects, access rules and
authentication, so WordPress, Laravel and similar applications run as
expected.
## PHP versions
| Version | Security fixes until | Notes |
| ----------- | ---------------------- | ---------------------------------- |
| **PHP 8.4** | 31 December 2028 | Default for new hostings |
| **PHP 8.3** | 31 December 2027 | |
| PHP 8.2 | 31 December 2026 | No longer offered for new hostings |
| PHP 8.1 | Ended 31 December 2025 | No longer offered for new hostings |
A hosting on PHP 8.1 or 8.2 keeps running, and its overview shows when
security support ends with an **Upgrade** button. The account's billing
contact is emailed 60, 30 and 7 days before that date, and once after it. A
hosting is never moved to another PHP version for you: a new version can break
a plugin or a framework, so you choose when.
### Changing the PHP version
Pick another version on the hosting's overview. The hosting restarts on the
new version, which interrupts it for a few seconds. Test your sites
afterwards. If something breaks, **Roll back** returns the hosting to the
version it had, for 7 days.
## Updates and rollback
Your hosting runs one exact build of PHP and the server software, and keeps
running that build until you change it. A restart never swaps it for a newer
one behind your back.
Patched builds of every PHP version are made weekly. When one is available
for your hosting's version, its overview shows **Security update available**
with an **Update now** button. Updating restarts the hosting on the new build,
which interrupts it for a few seconds. The overview shows **Updating** until
the hosting is serving on the new build again, and says so if it cannot start
on it, so you can roll back.
If an update fixes a critical vulnerability, or one that is known to be
exploited, the overview may also show a date after which it is applied
automatically, during the night between 02:00 and 05:00 Swedish time. The
account's billing contact is emailed when it appears and two days before.
Update before then to choose the moment yourself. A hosting is never stopped
because of a vulnerability.
### Rolling back
For 7 days after an update or a PHP version change, **Roll back** on the
overview returns the hosting to the build and PHP version it ran before. A
hosting that had not been on an exact build yet has nothing to return to
after an update, so **Roll back** appears only after a PHP version change. A
rollback is one step back and cannot itself be rolled back: update again when
you are ready. Rolling back an automatic security update postpones it, and it
is applied again after a new deadline.
Members with the viewer role see updates but cannot apply them.
## Sending email
PHP's `mail()` function, and everything built on it such as WordPress
password resets and contact forms, sends through Cosmoner Email once you turn
email on for the hosting. Until then `mail()` returns `false` and the reason is
written to the hosting's error log, so a form can tell its visitor the message
was not sent.
To turn it on:
1. Add a domain under **Email** in the control panel and finish its DNS setup,
so it is verified for sending. See [SMTP setup](/docs/guides/smtp-setup).
2. On the hosting's overview, choose that domain and the mailbox name to send
as, such as `wordpress@example.com`, and press **Turn on email**.
This creates an SMTP credential for the hosting, listed with the domain's
other credentials. Every email the hosting sends is billed on your project's
email plan, the same as any other email. All mail leaves as that one address:
a sender an application sets is replaced by it.
Press **Turn off** to stop sending; the credential is deleted. Deleting the
credential on the Email page turns the hosting's email off as well. Members
with the viewer role see the setting but cannot change it.
## Changing tiers
Change tier from the hosting's page. New PHP workers, memory and domain limits
apply straight away. If the hosting keeps backups for longer than 7 days, their
price follows the tier's storage. The disk size is set when the hosting is
created.
Source: https://cosmoner.com/docs/guides/web-hosting
---
# Databases
> How a managed PostgreSQL cluster is sized and billed, how to connect to it, and how to browse and edit your data from the panel.
## Overview
A project's databases are dedicated PostgreSQL clusters running on Cosmoner's
own hardware. You pick a version and a size, and pay a fixed monthly price for
that capacity — there is no allowance to run out of and nothing metered on top.
Patching is handled for you, and every cluster hands you a connection string
when it finishes provisioning.
## Always-on
A database is a dedicated PostgreSQL cluster sized by you. There is no sleeping,
no allowance and no usage cap: you are buying capacity, and the monthly price is
the same whether you use it or not.
You choose:
* **Version** — the PostgreSQL major version. It is fixed for the life of the
cluster, so pick the one your application and its extensions are ready for.
* **Size** — vCPU, memory and disk. The disk is part of the plan and is
included in its price; there is no separate storage line.
Every cluster is a single node, runs in Cosmoner's own data centre in Stockholm,
and is reachable from the apps in the same project and, over TLS, from anywhere
else. Storage is persistent and survives restarts and maintenance.
### Choosing a size
Size from the working set rather than the total data: PostgreSQL is fastest when
the rows you query often fit in memory. The smallest size is meant for
development, staging and side projects, and halves `max_connections` to 50 —
put a pooler in front of it if your framework opens more.
Move up a size when queries start reaching the disk rather than the cache, or
when the data outgrows the disk the size includes.
### Changing size
Every size is listed on the cluster's detail page next to your current one, with
what changes shown before anything is charged. A change applies prorated against
your billing period.
A size whose disk is smaller than the data the database already holds cannot be
selected. Free up space first.
## Creating one with an app
The app wizard can create a database for a new app in the same step: select
**Add a PostgreSQL database** when you configure the app. The database is the
same product as one created here, on the same plans and billed the same way,
and it appears in your project's database list. Its connection string is
stored as a project secret, `DATABASE_URL` by default, and given to the app,
so there is nothing to copy. The app's first deploy waits until the database is
online. See [Create an app with a database](/docs/guides/deploy-an-app#create-an-app-with-a-database).
Deleting the app does not delete its database, which keeps its data and its
billing until you delete it here.
## Connecting
When provisioning finishes, you are shown the host, database name, user,
password and a ready-made connection string. **The password is shown once** —
copy it into your secret manager or your app's environment before leaving the
page. See the [Secrets guide](/docs/guides/secrets) for storing it.
If you lose it, rotate the password from the database's detail page and update
whatever was using the old one. Rotating invalidates the previous password
immediately.
Each database has two addresses, and the detail page shows both.
### From your apps on Cosmoner
Apps in the same project connect to the **internal host**, which ends in
`.svc.cluster.local`. The connection never leaves the platform, so it is the
fastest path, and it is the one to put in your app's environment. No other
project can reach it.
### From anywhere else
Your laptop, a CI job, a migration tool or an app hosted elsewhere connects to
the **public host**:
```
pg-.postgres.cosmoner.com:5432
```
Every database is on port `5432`; the host name is what selects yours. TLS is
required, and a plaintext connection is refused with a message saying so:
```bash
psql "postgresql://app:@pg-.postgres.cosmoner.com:5432/app?sslmode=require"
```
The server certificate is publicly trusted, so a client can verify it without
downloading anything. With libpq 16 or newer, use
`sslmode=verify-full&sslrootcert=system`; most other drivers verify against the
system's certificates by default once TLS is on.
A few things to know:
* **The host name has to reach the server.** Clients send it during the TLS
handshake; libpq has done so since version 14, and current drivers for Node,
Python, Go, Java and .NET do too. A client too old for that can name the
database another way: add `options=endpoint%3Dpg-` to the connection
string, using the first part of your host name.
* **`channel_binding=require` is not supported.** Leave `channel_binding` at its
default, `prefer`; the password exchange is still protected by TLS.
* **Idle connections are closed after an hour.** Connection pools should
validate or recycle connections more often than that — most do by default.
* **Cancelling a query works as usual**, including Ctrl-C in `psql` and
statement timeouts set in your driver.
## Browsing your data
Every database has a **Data** tab on its detail page, next to Overview. It is
there for looking at what the database actually holds without installing a
client.
The tab opens a console in two halves. On the left is the table list: the
database it is connected to, a schema filter, a search box, and every table and
view the database's user can read, with a rough row count against each one. That
list stays where it is; the right-hand side switches between two views of
whatever you pick.
* **Grid** — the table's rows, fifty at a time. Click a column heading to sort by
it, step through pages from the toolbar, and add, edit or delete rows where
the database allows it.
* **SQL** — an editor for running statements. `Ctrl`/`Cmd` + `Enter` runs what is
in it, and clicking a table in the list writes its name into the query at the
cursor instead of opening it. A query's results sort too, though only the rows
that came back — sort the whole result with `ORDER BY`.
**Rows can be selected.** Tick the box at the left of a row, or the one in the
heading to take the whole page; hold `Shift` while ticking to take everything
between it and your last one. A bar above the rows then says how many are
selected and what can be done with them: *Copy as CSV* puts them on the
clipboard with a header line, and on a table you may write to, *Delete* removes
all of them at once. The selection is cleared whenever the rows underneath it
change — another table, another page, another sort.
Row counts, in the list and in the toolbar, are estimates from the database's
own statistics rather than exact counts.
The console fills the tab, and when that is not enough room it goes full screen
(`Esc` leaves it) or opens in a browser tab of its own. On a phone the table
list starts out of the way; the button at the left of the toolbar brings it
back.
### Reading and writing
The console can also change data, for members with the developer, admin or owner
role on the project.
**Rows are editable in place.** *Add row* opens a form with a field per column,
*Edit* opens the same form filled in from the row, and each row has a delete
button behind a confirmation — as does a selection of several, which is removed
in a single transaction, so either every selected row goes or none of them do. Columns you leave untouched keep their value — or
take the column default, when adding a row — so you can change one field without
restating the rest. Nullable columns have a *Set to null* box, since an empty
string and a null are different values.
Editing requires a way to name one row rather than every row that looks like it,
which means a **primary key**, and a table rather than a view. Tables without one
remain browsable; the console says so and points you at the SQL view, where you
write the `WHERE` clause yourself.
**The SQL view runs statements that write.** You can send several separated by
semicolons, and they all run inside **one transaction** — if any statement fails,
none of them are applied. Each statement reports what it did: rows returned for a
read, rows affected for a write. A few statements that act on the database server
rather than on your data are refused; run those with your own client.
Viewers see the tables and a read-only SQL view, and no edit controls.
### Limits
Two limits keep panel activity from disrupting the application using the same
database: a statement is stopped if it runs longer than a few seconds, and a
write waiting on a lock gives up quickly rather than holding on. Only the first
rows of a large result are returned. Add a `LIMIT` and a `WHERE` clause to work
within all three.
## Doing this over the API
A database can be created, resized and deleted over the API with a key that has
`databases: read, write` scope — see the
[API Keys guide](/docs/guides/api-keys) for issuing one. The endpoints are in the
[OpenAPI document](https://api.cosmoner.com/v1/openapi.json), which is generated
from the same schemas the API validates against.
## Related: shared databases
Shared databases are a separate, older product: a PostgreSQL database of its
own, with its own user, on a server shared with other databases. No new ones can
be created — create a database as above instead. Existing shared databases keep
working; see the [Shared databases reference](/docs/api/shared-databases) for
connecting to one and deleting it.
Source: https://cosmoner.com/docs/guides/databases
---
# Connect to Redis
> Create a managed, Redis-compatible Valkey database in Stockholm, connect to it over TLS from your app or your machine, and choose how much of it survives a restart.
## Overview
Redis in Cosmoner runs **Valkey** — the Linux Foundation's BSD-licensed fork of
Redis 7.2, compatible with it command for command. Any Redis client library
connects to it unchanged: use it for caching, sessions, rate limiting, queues
and pub/sub as you would Redis.
Every database runs on Cosmoner's own hardware in Stockholm, so its data stays
in Sweden. Connections are TLS-only and need a password.
Plans and prices are on [Valkey & Redis Pricing](/docs/pricing/redis).
## Create a database
In your project, go to **Redis** and create a database. You choose:
* **Plan** — how much data it holds. Plans differ in dataset size, not in speed:
a single instance runs commands on one thread whichever plan it is on.
* **Persistence** — how much of the data survives a restart. See
[Choosing persistence](#choosing-persistence).
When it is ready, its page shows the host, port, password and a ready-made
**Connection string**.
## Connect
The connection string looks like this:
```text
rediss://:@valkey-.valkey.cosmoner.com:6379
```
Note the scheme: **`rediss://`**, with two `s`. TLS is required, and a client
given `redis://` tries a plaintext connection that is refused.
### From your app
Store the connection string as a [secret](/docs/guides/secrets) — it contains
the password — and link it to your app as an environment variable, e.g.
`REDIS_URL`, with **Link secret**. Then redeploy the app so it picks the
variable up.
**Node.js**, with [`redis`](https://www.npmjs.com/package/redis):
```js
import { createClient } from "redis";
const client = createClient({ url: process.env.REDIS_URL });
client.on("error", (err) => console.error("Redis error", err));
await client.connect();
await client.set("greeting", "hej", { EX: 60 });
```
**Node.js**, with [`ioredis`](https://www.npmjs.com/package/ioredis):
```js
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
```
**Python**, with [`redis`](https://pypi.org/project/redis/):
```python
import os
import redis
r = redis.Redis.from_url(os.environ["REDIS_URL"])
r.set("greeting", "hej", ex=60)
```
**PHP**, with [Predis](https://github.com/predis/predis):
```php
$client = new Predis\Client(getenv('REDIS_URL'));
$client->set('greeting', 'hej');
```
All of these read the `rediss://` scheme and turn TLS on themselves.
### From your machine
The host is reachable from anywhere, so you can inspect a database with
`redis-cli` or a GUI client:
```bash
redis-cli --tls -u "rediss://:@valkey-.valkey.cosmoner.com:6379"
```
Treat the password like any other credential: it is the only thing between the
internet and your data.
## Choosing persistence
Persistence is included in every plan, and the mode never changes the price.
| Mode | What can be lost on a restart | Good for |
| ---------------------------------------------------- | --------------------------------------- | ---------------------------------------------------- |
| **None** | Everything — no durability is promised. | A pure cache you can rebuild. |
| **Append-only file, every second** | At most one second of writes. | Queues and session stores — the usual middle ground. |
| **Append-only file, every write** | Nothing that was acknowledged. Slowest. | Data you cannot afford to lose a write of. |
| **Snapshot every hour**, **6 hours** or **12 hours** | Writes since the last snapshot. | Data that changes slowly or can be partly rebuilt. |
## When it is full
A plan's dataset size is its memory limit. When the data reaches it, Valkey
makes room by evicting the **least recently used keys** — any key, whether or
not it has an expiry. Writes keep succeeding; old data goes.
That is what you want from a cache, and a surprise anywhere else. If you keep
data that must not disappear, such as a job queue, size the plan for it and
watch its usage, or keep that data in a database instead.
## Limits
* **No high availability.** A database is a single instance with no automatic
failover. Plan for it being briefly unreachable during maintenance, and make
your app treat a cache miss or a connection error as recoverable.
* **One region:** Stockholm, Sweden.
## Troubleshooting
**Connection refused or reset immediately.** The URL uses `redis://`. Change it
to `rediss://`.
**`WRONGPASS` or `NOAUTH`.** The password in the URL is wrong or missing — copy
the connection string from the database's page again.
**Keys disappear without expiring.** The dataset reached the plan's size and the
least recently used keys were evicted. Move to a larger plan, or set expiries so
the data you keep is the data you use.
Source: https://cosmoner.com/docs/guides/redis
---
# Object Storage
> Choose the right storage tier, understand how usage above it is billed, set an overage limit, and manage your S3-compatible buckets.
## Overview
Cosmoner object storage provides S3-compatible buckets. Each bucket runs on a prepaid tier that includes storage, egress, and request allowances. Usage above the allowances is billed as overage, and you can set a monthly limit on it for each bucket.
## Tiers
| Tier | Price | Storage | Egress | Requests |
| -------------- | ---------- | ------- | ------ | -------- |
| **Starter** | USD 5/mo | 25 GB | 10 GB | 500K |
| **Growth** | USD 20/mo | 100 GB | 50 GB | 2M |
| **Scale** | USD 75/mo | 500 GB | 250 GB | 5M |
| **Enterprise** | USD 300/mo | 2 TB | 1 TB | 20M |
Egress and requests are per billing period. All tiers run on the same storage; the difference is in the included allowances. A bucket keeps the allowances its tier had when it was created or last changed tier.
## Choosing a Tier
Consider all three dimensions — storage, egress, and requests — when selecting a tier. A small dataset with high read traffic may need a higher tier than a large archive that is rarely accessed.
* **Starter** — Development, prototyping, or low-traffic assets (marketing sites, small apps).
* **Growth** — Production workloads with moderate traffic. Good for most SaaS applications.
* **Scale** — Media-heavy applications, CDN origins, or services with significant egress.
* **Enterprise** — Large-scale production workloads, data lakes, or high-throughput APIs.
If you're unsure, start with **Starter** or **Growth** and upgrade later. Upgrades are instant and prorated to your billing cycle, and going a little over the allowance in the meantime is billed as overage rather than stopping the bucket.
## Usage Monitoring
Cosmoner tracks storage, egress, and request usage per bucket once a day and
compares it against the bucket's allowances.
### Warning Threshold (80%)
When any dimension reaches 80% of its allowance, you receive an in-app
notification and email alert, once per billing period. No action is taken on
the bucket — this is an early warning to consider upgrading.
### Usage Above the Plan
A bucket that goes past an allowance keeps working. What it uses above the
allowance is billed as overage: storage per GB-month, egress per GB, and
requests per 10,000. The rates are on the
[Object Storage pricing](/docs/pricing/object-storage) page.
### Overage Limit
To cap what a bucket can run up, set a monthly overage limit from the bucket's
page, and choose what reaching it does:
* **Alert** emails the billing contact, once per billing period. The bucket
keeps working.
* **Block** emails the billing contact and stops the bucket's files from being
read or written — through access keys, public links and the CDN — until the
next billing period starts. Listing and deleting files still work, and no
data is deleted.
Raising or removing the limit, switching to **Alert**, or upgrading the tier
unblocks a blocked bucket immediately. The same setting is available via the
API:
```
PATCH /v1/projects/:projectId/storage/object-storage/:bucketId/overage-limit
```
```json
{
"overageLimitUsd": 50,
"overageLimitAction": "BLOCK"
}
```
Send `"overageLimitUsd": null` to remove the limit.
## Changing Plans
The bucket overview page lists every tier next to your current one. Selecting a
tier opens a confirmation dialog showing how your allowances change and exactly
what the change costs before anything is charged.
Tier changes are:
* **Instant** — the new tier's allowances apply immediately.
* **Prorated** — billing is adjusted for the remaining billing period.
The same change is available via the API:
```
PATCH /v1/projects/:projectId/storage/object-storage/:bucketId/tier
```
```json
{
"tier": "GROWTH"
}
```
### Upgrading
The prorated difference for the rest of the current billing period is charged to
your payment method immediately, and the new monthly price applies from your next
invoice. If the bucket was blocked at its overage limit, upgrading unblocks it.
To see the amount before committing, call the preview endpoint:
```
GET /v1/projects/:projectId/storage/object-storage/:bucketId/plan-preview?tier=GROWTH
```
### Downgrading
You can downgrade to a lower tier if your current usage fits within the target
tier's limits. If any dimension (storage, egress, or requests) exceeds the lower
tier's allowance, the downgrade is blocked — reduce usage or wait for the next
billing cycle before retrying.
Downgrades charge nothing. The unused part of your current plan is credited to
your account balance and applied to future invoices rather than refunded to your
card.
## Creating a Bucket
```
POST /v1/projects/:projectId/storage/object-storage
```
```json
{
"name": "my-bucket",
"provider": "AWS_S3",
"region": "eu-north-1",
"tier": "STARTER"
}
```
Bucket names are globally unique, 3–33 characters, lowercase letters, numbers, and hyphens only.
If your project does not have an active subscription, the API returns a Stripe Checkout URL to complete payment. Once payment is confirmed, the bucket is provisioned automatically.
## Regions
Buckets can be created in any of the following AWS regions:
| Region | Location |
| ---------------- | ------------------------ |
| `us-east-1` | US East — N. Virginia |
| `us-east-2` | US East — Ohio |
| `us-west-1` | US West — N. California |
| `us-west-2` | US West — Oregon |
| `eu-west-1` | Europe — Ireland |
| `eu-central-1` | Europe — Frankfurt |
| `eu-north-1` | Europe — Stockholm |
| `ap-southeast-1` | Asia Pacific — Singapore |
| `ap-northeast-1` | Asia Pacific — Tokyo |
Choose a region close to your application servers to minimize latency and egress costs.
## Public Access & CDN
By default, buckets are private — objects can only be accessed with signed credentials. There are two ways to serve objects without them, and you can use either or both:
* **Public access** (`publicAccess: true`) serves objects over a public, unauthenticated HTTPS URL, shown as the **Public URL** on the bucket overview page.
* **CDN** (`cdnEnabled: true`) serves objects from edge locations worldwide for lower latency and faster delivery, shown as the **CDN URL** once provisioned. The CDN does not need public access: on a private bucket, the CDN URL is the only way to read objects without credentials.
### Serving only some folders
Both serve the whole bucket unless you list folders in `publicPaths`. Then only objects under those folders are served — on the Public URL and the CDN alike — and everything else still needs credentials. This lets one bucket hold private files next to, say, the images your emails link to.
* A folder covers everything beneath it: `images/` serves `images/logo.png` and `images/2026/hero.jpg`, but not `images-old/`.
* Folders use letters, digits, `.`, `_`, `-` and `/`. A leading `/` is dropped and a trailing one is added for you.
* Up to 10 folders. An empty list means the whole bucket.
Set them when creating the bucket, or change them later on the bucket overview page or with:
```
PATCH /v1/projects/:projectId/storage/object-storage/:bucketId/access
```
```json
{
"publicAccess": false,
"publicPaths": ["email/", "images/"]
}
```
Send both fields — together they replace the bucket's current settings.
### CDN pricing
The CDN is an opt-in add-on:
* Delivery is metered at **USD 0.12/GB** and **USD 0.015 per 10K requests**, billed as extra line items on the bucket's subscription.
* It can be toggled on or off at any time, either from the bucket overview page or via the API:
```
PATCH /v1/projects/:projectId/storage/object-storage/:bucketId/cdn
```
```json
{
"cdnEnabled": true
}
```
Disabling the CDN removes the added cost. The bucket stays reachable on its Public URL if `publicAccess` is on, and becomes fully private if it is not.
## CORS
If your bucket is accessed directly from a browser (for example, uploading files from a web app or serving public assets to a single-page application), you need Cross-Origin Resource Sharing (CORS) rules so the browser allows those cross-origin requests.
You can define CORS rules when creating a bucket, and edit them at any time from the bucket's settings. Each rule specifies:
* **Allowed origins** — the web origins permitted to make requests (e.g. `https://example.com`), or `*` for any origin.
* **Allowed methods** — one or more of `GET`, `PUT`, `POST`, `DELETE`, `HEAD`.
* **Allowed headers** *(optional)* — request headers the browser may send (use `*` to allow all).
* **Expose headers** *(optional)* — response headers the browser is allowed to read (e.g. `ETag`).
* **Max age** *(optional)* — how long, in seconds, the browser may cache the preflight response.
To update CORS on an existing bucket:
```
PATCH /v1/projects/:projectId/storage/object-storage/:bucketId/cors
```
```json
{
"corsRules": [
{
"allowedOrigins": ["https://example.com"],
"allowedMethods": ["GET", "PUT"],
"allowedHeaders": ["*"],
"maxAgeSeconds": 3600
}
]
}
```
The request replaces the entire CORS configuration — send the complete rule set each time. An empty array removes all CORS rules.
## Deleting a Bucket
```
DELETE /v1/projects/:projectId/storage/object-storage/:bucketId
```
Deleting a bucket is irreversible. All objects stored in it are permanently removed and the associated subscription item is cancelled.
Source: https://cosmoner.com/docs/guides/object-storage
---
# Container Registry
> Push and pull private images from a managed Cosmoner registry, choose a region and retention policy, and understand storage and egress pricing.
## Overview
A container registry stores the images your apps are deployed from. A Cosmoner registry is a managed registry backed by Amazon ECR: you pick a region and a retention policy, Cosmoner handles the infrastructure, and you authenticate with a Cosmoner API key instead of cloud credentials.
You get a normal registry: `docker push` and `docker pull` from your laptop or CI with a Cosmoner API key, and AWS credentials on request for tooling that insists on speaking to ECR directly.
### Deploying an app from your registry
When you create an app from a container image, the repositories in your project's registries are listed alongside Docker Hub and the GitHub Container Registry. Pick one and choose a tag — there are no credentials to enter, because an app deployed from your own registry is authenticated for you.
By default an app deploys the tag it was given, and follows it: pushing a new image over that same tag automatically redeploys every app deployed from that repository and tag, and the rollout appears in each app's activity feed. Pushes to other tags leave the app alone, so a push to `v3` never moves an app deployed from `v2`. An app you have stopped is never restarted by a push.
Each app's **Deploy on push** setting changes that. Choose it when you create the app, or later under the app's General tab:
* **Same tag** — the default described above.
* **Newest image** — every push to the repository deploys, whatever its tag, and the app switches to the tag that was pushed. Pushing one image under two tags at once, such as `latest` and a commit SHA, deploys it once.
* **Off** — pushes never move the app. Deploy it yourself from CI with the [Deploy Image](/docs/api/apps#deploy-image) endpoint, naming the tag or digest you just pushed.
An app deployed this way is otherwise an ordinary app: it runs on the platform's infrastructure alongside apps built from git, and it can scale out to multiple instances (autoscaling is not available). The one difference is build logs — there are none, because the image is already built, so the logs you see are your container's own output.
## Pricing
A Cosmoner registry is billed on a flat monthly fee plus what you store and what you pull. There is no charge for the number of repositories you create inside a registry.
| What | Rate | Notes |
| ---------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------- |
| **Registry fee** | USD 1 per month | Charged per registry, prorated from the day you create it. |
| **Storage** | USD 0.25/GB per month | Measured across every repository in your organization. |
| **Egress** | First 10 GB free, then USD 0.15/GB | Data pulled from your repositories. The allowance covers your whole organization each billing period. |
| **Pushes** | USD 0 | Uploading images is never charged. |
### How storage is measured
Cosmoner measures the size of your images once a day, at 02:00 UTC, and totals them across all repositories in your organization. Your invoice reflects the most recent daily measurement in the billing period, so the amount you pay tracks what you were actually storing near the end of the cycle rather than a peak earlier in the month.
Two practical consequences:
* Deleting large images — or letting a retention policy expire them — lowers your bill from the next measurement onward.
* Images pushed and deleted between two daily measurements are never measured, and so are never billed.
### How egress is measured
Egress is the compressed size of the image layers your clients download, counted as each pull happens rather than once a day.
Only the layers actually transferred are counted. Docker and Kubernetes skip layers they already have on the machine, so pulling the same image again on a host that already has most of it costs a fraction of the image's full size. Pushes, layers a client already holds, and requests that only check whether an image exists are not counted.
The first 10 GB in each billing period are included across your whole organization, however many registries you have. Beyond that, egress is billed at USD 0.15/GB.
A few ways to stay inside the allowance:
* Set `imagePullPolicy: IfNotPresent` rather than `Always` where a rebuild isn't expected on every start.
* Keep base layers stable between builds, so only the layers that changed are pulled.
* Build smaller images — a multi-stage build that ships only the runtime artefacts is cheaper to pull as well as faster.
All three charges appear as separate line items on the project's subscription. See [Billing & Payments](/docs/guides/billing-and-payments) for how invoicing and proration work in general.
### Estimating a cost
Before creating a registry you can preview the registry fee, including proration, tax, and any credit applied. Storage and egress are metered after the fact and are not part of the preview:
```
GET /v1/projects/:projectId/storage/container-registry/preview
```
## Creating a Registry
```
POST /v1/projects/:projectId/storage/container-registry
```
```json
{
"name": "my-registry",
"region": "eu-north-1",
"imageTagMutability": "MUTABLE",
"scanOnPush": true,
"encryptionType": "AES256",
"lifecycleEnabled": true,
"lifecycleKeepLastN": 10,
"lifecycleUntaggedAfterDays": 7
}
```
| Option | Default | What it does |
| ---------------------------- | --------- | ------------------------------------------------------------------------------ |
| `region` | — | Where your images are stored. Fixed once the registry exists. |
| `imageTagMutability` | `MUTABLE` | `IMMUTABLE` rejects a second push to a tag that already exists. |
| `scanOnPush` | `true` | Scans each pushed image against known vulnerabilities. |
| `encryptionType` | `AES256` | Cosmoner-managed encryption. `KMS` uses your own key and requires `kmsKeyArn`. |
| `lifecycleEnabled` | `true` | Expires old images automatically. |
| `lifecycleKeepLastN` | `10` | Keeps this many recent images (1–1000); older ones expire. |
| `lifecycleUntaggedAfterDays` | `7` | Untagged images expire after this many days (1–365). |
Tag mutability and scanning can be changed later and are re-applied to every existing repository. Encryption applies only to repositories created afterwards, because it is fixed when a repository is created — and the region cannot be changed at all.
The registry fee is charged to the project's payment method as soon as the registry is created, prorated for the rest of the billing period. If the project has no payment method, the request returns `402` — see [Billing](/docs/api/billing).
Creating a registry does not create anything to push to yet. It reserves the name, stores the settings above as the defaults for the repositories you add under it, and starts the fee. Add at least one repository before your first push.
## Regions
```
GET /v1/projects/:projectId/storage/container-registry/regions
```
| Region | Location |
| ---------------- | ------------------------ |
| `us-east-1` | US East — N. Virginia |
| `us-east-2` | US East — Ohio |
| `us-west-2` | US West — Oregon |
| `eu-west-1` | Europe — Ireland |
| `eu-central-1` | Europe — Frankfurt |
| `eu-north-1` | Europe — Stockholm |
| `ap-southeast-1` | Asia Pacific — Singapore |
| `ap-southeast-2` | Asia Pacific — Sydney |
| `ap-northeast-1` | Asia Pacific — Tokyo |
Choose a region close to where your apps run, so deployments pull images over the shortest path. The regions endpoint is the source of truth — not every region above is open in every environment.
## Repositories
A registry holds repositories, one per image:
```
POST /v1/projects/:projectId/storage/container-registry/:id/repositories
```
```json
{
"name": "my-app"
}
```
Names must be lowercase and may contain dots, hyphens, and underscores. Creating a repository costs nothing on its own — you are billed only for what you push into it.
A repository name is unique across the whole project, not just within its registry. The path you push to is your project and the repository name — it does not say which registry — so the same name in two registries would leave a `docker push` with nowhere definite to go. Creating a repository whose name is already used in another registry of the project is refused, and the response names that registry.
A repository is where images actually live: the registry above it is a namespace and a set of defaults, and the repository is what a `docker push` addresses.
## Public Repositories
A repository is private by default — pulling it needs a Cosmoner API key belonging to a member of your project. Making it public lets anyone pull it with no credentials at all, which is what you want for a base image, a CLI, or anything else you publish:
```
PATCH /v1/projects/:projectId/storage/container-registry/:id/repositories/:repoId
```
```json
{
"visibility": "PUBLIC",
"publicEgressCapGb": 50
}
```
Pushing is never anonymous. Public affects pulls only, and a push still needs an API key.
### The egress cap
**Anonymous pulls are billed to you**, at the same USD 0.15/GB as your own, once your organization's 10 GB monthly allowance is spent. A repository that gets popular — or scraped — can move a lot of data without anyone on your team doing anything, so a public repository must carry a monthly egress cap.
When public egress reaches the cap, the repository is made private again. Pulls from your own project keep working with an API key; anonymous pulls stop. That is deliberate: the cap is a spending limit, and the safe thing to do at the limit is stop serving strangers rather than keep billing you.
* The cap counts only anonymous egress. Pulls made with an API key are billed as usual but do not count against it.
* The counter resets at the start of each billing period, on the 25th.
* Making the repository public again — or raising the cap after it was reached — starts a fresh allowance.
* Set the cap to what you are willing to spend in a bad month, not to what you expect: `50` GB is roughly USD 6 of egress once the free allowance is gone.
## Pushing & Pulling
Log in with a Cosmoner API key. There are no cloud credentials to create or rotate:
```bash
docker login registry.cosmoner.com -u _token -p
docker push registry.cosmoner.com//:
docker pull registry.cosmoner.com//:
```
The path is always your organization ID, lowercased, followed by the repository name — the control panel shows the exact line to copy on each repository's page. The username in `docker login` is ignored — `_token` is a placeholder saying "this is not an account name", and any value works. Your Cosmoner API key goes in the password field, and there is no separate key id and secret.
The key has to carry the registry scope, and it has to belong to a member of the organization that owns the repository: `registry:read` to pull, `registry:write` to push. A key without it is refused at `docker login` rather than at push time. See [API Keys](/docs/guides/api-keys) for creating one and granting scopes.
The registry answered on `api.cosmoner.com` before it moved to its own hostname, and still does, so nothing already pushing needs changing today. Move to `registry.cosmoner.com` when convenient: it is the hostname that accepts large image layers, and the API hostname will stop serving the registry eventually.
## Browsing Images
Open a repository from its registry in the control panel to see what is actually stored in it: every image with its tags, size, digest, when it was pushed, and when it was last pulled. Untagged images — the ones left behind when a tag is moved to a newer push — are listed too, because they still take up storage you are billed for.
The same list is available over the API:
```
GET /v1/projects/:projectId/storage/container-registry/:id/repositories/:repoId/images
```
An individual image can be deleted by digest, which also removes every tag pointing at it:
```
DELETE /v1/projects/:projectId/storage/container-registry/:id/repositories/:repoId/images/:digest
```
Deleting an image is immediate and permanent — anything pulling that tag, or pinning that digest, starts failing. For routine cleanup, prefer a retention policy over deleting by hand where retention runs automatically — see [Retention](#retention).
## AWS Credentials
`docker push` and `docker pull` from your laptop or CI only need an API key. You only need this section if something insists on talking to ECR natively — `aws ecr get-login-password`, a Kubernetes image pull secret, or a build system that wants a registry ARN.
### Creating them, step by step
**1. Open IAM in the control panel.** It lives under your project, and it is the only place registry credentials are created. Each repository's page also links straight here, filtered to that repository.
**2. Choose a label and a scope.**
The label is how you will recognise these credentials later — name them after the thing that uses them (`ci-pipeline`, `staging-cluster`), not after yourself.
The scope decides which repositories the credentials reach. Pick the single repository whenever you can: a build that pushes one image has no reason to reach the others, and if the key leaks, the blast radius is one repository instead of every image your project owns. **Every repository in this project** exists for the cases where one credential genuinely serves many images.
**3. Choose the access level.**
* **Push & pull** — the right default for a CI pipeline that builds and uploads the image. It can do everything pull-only can.
* **Pull only** — use this when something else does the pushing: a Kubernetes cluster, a deploy runner, a partner who consumes your image. If the credential never needs to upload, do not let it.
The rule of thumb: give push access to the one place images are built, and pull-only access to everything that merely runs them. A leaked pull-only key exposes your images; a leaked push key lets someone replace them.
**4. Save the secret. This is your only chance.**
AWS returns the secret access key once and Cosmoner never stores it. Copy it into your CI secret store or password manager before closing the dialog — if you lose it, delete the credentials and create a new set.
The dialog also shows the `docker login` line for these credentials, with the region and the registry endpoint already filled in. Copy it as it stands: these credentials authenticate against that endpoint, not against `registry.cosmoner.com`, and a `docker login` with no registry argument logs in to Docker Hub instead and only fails later, at push time.
The same API is available directly:
```
POST /v1/projects/:projectId/iam/container-registry
```
```json
{
"label": "ci-pipeline",
"access": "push",
"repositoryId": "cmsh95uld0000ce9kkchanq4y"
}
```
`access` is `pull` (read-only) or `push` (push and pull). `repositoryId` scopes the credentials to one repository; omit it and they reach every repository in the project.
### What to know before using them
Each repository's page shows how many credentials can reach it and links straight to IAM filtered to that repository, so you can answer "what outside Cosmoner has access to this image?" without walking the whole list.
* **Scoping stops at the repository.** A repository is named `/` inside ECR, with no registry in the path, so credentials can cover one repository or the whole project — there is no way to scope them to a single registry.
* **Pulls made with them are not metered as egress.** The traffic goes straight from AWS to your client instead of through Cosmoner, so those pulls do not count toward your egress bill. Storage is billed as usual.
Delete credentials you no longer need — anything using the key stops working immediately:
```
DELETE /v1/projects/:projectId/iam/container-registry/:iamUserName
```
## Retention
Whether retention runs on its own depends on the provider your registry was created on.
On an **Amazon Web Services** registry, retention is enforced by the registry itself rather than at push time, so images are cleaned up on a schedule — expect expiry to happen within a day of an image becoming eligible, not the moment it does.
On a **Cosmoner** registry — one running on Cosmoner's own infrastructure — automatic cleanup is not available yet. Retention settings are saved with each repository and will take effect once it is; until then, delete images you no longer need from the panel or the API to reclaim storage.
The registry's retention settings apply to every new repository, and each repository can override them:
```
PATCH /v1/projects/:projectId/storage/container-registry/:id/repositories/:repoId
```
Because storage is what you pay for, keeping old images from accumulating — by policy where retention runs automatically, by deleting them where it does not yet — is the most direct way to control the cost of a registry.
## Deleting a Registry
```
DELETE /v1/projects/:projectId/storage/container-registry/:id
```
Deleting a Cosmoner registry removes its repositories and all images in them permanently, and stops all three of its charges — the monthly fee, storage, and egress. Metered usage already recorded in the current period is still invoiced.
Deleting a single repository works the same way, without touching the rest of the registry:
```
DELETE /v1/projects/:projectId/storage/container-registry/:id/repositories/:repoId
```
Full endpoint details are in the [storage API reference](/docs/api/storage).
Source: https://cosmoner.com/docs/guides/container-registry
---
# Billing & Payments
> Understand how billing works, what happens when payments fail, and how credits and refunds work when you remove resources.
## How Billing Works
This guide covers the mechanics of paying — invoices, payment methods, credits,
and what happens when a charge fails. For how each product is measured and what
changes its price, see [Pricing](/docs/pricing).
Cosmoner uses Stripe for subscription billing. Each project has a subscription that covers all provisioned resources — servers, apps, databases, object storage buckets, and container registries. You are billed monthly on the **25th of each month**.
### Pre-charging and Proration
When you deploy a new resource, you are charged immediately for the prorated amount from the deployment date to the next 25th. From then on, the full monthly price is billed on each billing cycle.
For example, if you deploy a USD 24/month app on the 10th, you would pay approximately USD 12 upfront (15 days of the 30-day cycle), and then USD 24 on the 25th for the next full month.
The prorated charge happens **automatically against the project's payment method** — there is no separate checkout step and no card entry when deploying. Any project member with write access can deploy; the charge always goes to the project's card, not the person clicking deploy.
## The Project Payment Method
Each project has a single payment method that all of its resources are billed to. It is managed by the project's **biller** (see [Members](/docs/api/members#transfer-billing-responsibility)).
* **The biller** adds and manages the card under **Settings → Billing**. This is the card every resource in the project is charged to.
* **Other members** can deploy resources, but cannot see or manage the card. If the project has no card yet, a non-biller who tries to deploy is prompted to **notify the biller** (by email and in-app) to add one — they are never asked for their own card.
If you deploy as the biller and the project has no card yet, you add one in place and the deployment continues automatically.
## Billing Email
By default, invoices and receipts go to the biller's account email. You can override this per project under **Settings → Billing** by setting a **billing email** — it then receives invoices and receipts sent by Stripe, as well as Cosmoner's own billing notifications (payment confirmations, payment failures, domain renewal invoices). A shared inbox or distribution list (for example `billing@yourcompany.com`) works well if several people need these emails.
Clearing the billing email reverts delivery to the biller's account email. Changing the biller (see [Members](/docs/api/members#transfer-billing-responsibility)) also changes where billing emails go when no override is set.
## Trial credit
A new customer's first project receives USD 5.00 of credit, once, when its biller saves the project's first card. The control panel's card form shows the offer before you save, and the credit is added to the project's **credit balance** as soon as the card is accepted.
* It is applied automatically to the project's upcoming invoices, like any other credit.
* It is for new customers only: a project or person that has already received it, or has already paid an invoice, does not receive it again. Prepaid cards do not qualify.
* It can be spent on invoices but not paid out. [Refunding the credit balance](#refunding-the-credit-balance-instead) leaves any unspent trial credit on the balance and refunds only the rest.
## Removing Resources and Credits
When you remove a resource (delete an app, server, database, or storage bucket), billing stops immediately, and the unused portion of the current billing period is credited back to you.
### Credit on removal
Removing a resource generates a prorated credit for the unused days, which is added to your project's **credit balance** — this happens whether or not you still have other active resources. For example, if you delete a USD 24/month app halfway through the billing cycle, you receive a \~USD 12 credit.
By default, the credit balance is applied **automatically to your upcoming invoices and payments**. No action is required on your part.
### Refunding the credit balance instead
If the project's biller would rather have the credit balance returned than applied to future invoices, they can request a refund to the original payment method(s) from the billing page (or as part of a [biller transfer](/docs/api/members#transfer-billing-responsibility)).
Refunds are issued **minus non-recoverable payment-processing fees** — our payment processor does not return its transaction fees on refunded charges, so the amount refunded is always less than the credit balance being refunded. Refunds typically take 5–10 business days to appear on your statement. See the [Payment Policy](/legal/payment-policy) for full details.
Cosmoner does not issue automatic refunds to a payment method under any circumstances, including when you remove your last active resource.
## Service Agreements
A service agreement (Code Maintenance or Managed Operations) is a contract for ongoing work rather than a resource, and it bills differently:
* **It joins the existing subscription.** A project needs at least one paid resource before an agreement can be signed.
* **It is billed monthly in advance, in whole months.** Nothing is charged for the part-month the agreement starts in. The first full fee falls on your next invoice.
* **Ending it does not create a credit.** You give notice from the control panel, and billing continues until the notice period has run, or until the end of a commitment period you chose if that is later. Fees already paid are not refunded.
Only the project's biller can sign or end an agreement, and only from the control panel. API keys cannot.
## Payment Lifecycle
When an invoice is generated, Stripe attempts to charge your default payment method. If the charge succeeds, no action is needed. If it fails, the subscription enters a retry period.
### Past Due
A subscription becomes **past due** when the initial payment attempt fails and Stripe begins retrying. During this state:
* **Servers** are stopped.
* **Object storage buckets** are suspended (reads and writes blocked, data preserved).
* **Your account is blocked** — you cannot create or manage resources until the balance is settled.
Stripe retries the charge several times over the following days. If any retry succeeds, the subscription returns to active and your account is unblocked automatically.
### Unpaid
If all retry attempts fail, the subscription moves to **unpaid**. This is a more severe state:
* **Servers** are terminated and their data is lost.
* **Object storage buckets** are suspended (data preserved, but access blocked).
* **Your account remains blocked.**
At this point, automatic retries have stopped. You must take manual action to restore your account.
## Resolving a Blocked Account
The project's biller resolves a blocked project:
1. **Update the project payment method** — Under **Settings → Billing**, add or replace the card. Make sure it has sufficient funds. The biller-only endpoint is:
```
POST /v1/projects/:projectId/billing/payment-methods
```
```json
{
"paymentMethodId": "pm_..."
}
```
The card supplied here always becomes the project's default. Adding a replacement is how you swap a declined card.
2. **Settle the outstanding balance** — Pay any open invoices from the project's billing dashboard.
3. **Wait for reactivation** — Once the payment succeeds, your subscription returns to **active**. Cosmoner automatically:
* Unblocks your account and restores access to the dashboard.
* Reactivates suspended object storage buckets.
* Note: terminated servers cannot be recovered — you will need to redeploy them.
## Avoiding Payment Failures
* Keep your default payment method up to date.
* Monitor billing notifications — Cosmoner sends email alerts when payments fail.
* Check your invoices regularly from the billing dashboard or API.
* If you expect a large bill (e.g., adding multiple servers or upgrading tiers), confirm your payment method has sufficient limits.
## Payment Methods
The biller manages the project's card via the dashboard or the project billing API. To add a card, first create a setup intent (which returns a client secret to confirm the card in the browser), then register the confirmed payment method:
| Action | Endpoint |
| ------------------- | -------------------------------------------------------- |
| Start adding a card | `POST /v1/projects/:projectId/billing/setup-intent` |
| List methods | `GET /v1/projects/:projectId/billing/payment-methods` |
| Add / replace card | `POST /v1/projects/:projectId/billing/payment-methods` |
| Set default | `PATCH /v1/projects/:projectId/billing/payment-methods` |
| Remove a method | `DELETE /v1/projects/:projectId/billing/payment-methods` |
All of these endpoints are restricted to the project's biller (the pending biller of an in-flight transfer may also add a card while accepting). You cannot remove the default card while the project has an active subscription — add a replacement first.
Cards are held on the project, not on your account: resource billing runs entirely on the project card, and no card is collected at signup. The older account-level payment-method endpoints have been removed.
Source: https://cosmoner.com/docs/guides/billing-and-payments
---
# SMTP Setup
> Send transactional email from your own domain using Cosmoner's SMTP service.
## Prerequisites
* A project with a payment method
* A domain added to your project (purchased or migrated)
## 1. Enable Email for Your Domain
Enable email sending on a domain in your project:
```
POST /v1/projects/:projectId/email
```
```json
{
"domainId": "your-domain-id"
}
```
This generates DKIM keys and returns the DNS records you need to add.
## 2. Add DNS Records
The response includes three DNS records to add at your DNS provider:
| Type | Name | Purpose |
| ----- | ------------------------------------- | ----------------- |
| `TXT` | `datablock._domainkey.yourdomain.com` | DKIM signature |
| `TXT` | `yourdomain.com` | SPF authorization |
| `TXT` | `_dmarc.yourdomain.com` | DMARC policy |
Add all three records. DNS propagation can take up to 48 hours, but usually completes within minutes.
## 3. Verify DNS
Once your DNS records are in place, trigger verification:
```
POST /v1/projects/:projectId/email/:emailDomainId/verify
```
If the records resolve correctly, your domain status changes to `ACTIVE` and you can start sending email.
Every email is billed, so the project needs a payment method before a domain
can go active. If it has none, verification answers `402` and the control panel
asks for a card, then retries. See [Email Pricing](/docs/pricing/email).
## 4. Create SMTP Credentials
Create credentials for your application to authenticate with:
```
POST /v1/projects/:projectId/email/:emailDomainId/credentials
```
```json
{
"label": "Transactional",
"fromAddress": "noreply@yourdomain.com"
}
```
The response includes a password that is shown **once** and cannot be retrieved again. Store it securely.
The `fromAddress` must belong to the verified domain. It is also the only sender
address that this credential can use.
```json
{
"success": true,
"data": {
"id": "...",
"label": "Transactional",
"fromAddress": "noreply@yourdomain.com",
"smtpUsername": "smtp_abc123",
"smtpPassword": "generated-password"
}
}
```
## 5. Send Email
Use the credentials with any SMTP client or library. Example configuration:
| Setting | Value |
| ------------ | ------------------------------------- |
| **Host** | `smtp.cosmoner.com` |
| **Port** | `587` |
| **Username** | The `smtpUsername` returned in step 4 |
| **Password** | The password from step 4 |
| **From** | `noreply@yourdomain.com` |
The relay replaces the message's `From` header with the credential's configured
address. Create a separate credential for each sender address you need.
To keep an email's wording and layout in the control panel rather than in your
code, store it as an [email template](/docs/guides/email-templates) and send it
by its alias through the API. The credential still decides the sender.
### Node.js Example (Nodemailer)
```js
const transporter = nodemailer.createTransport({
host: "smtp.cosmoner.com",
port: 587,
secure: false,
auth: {
user: "smtp_abc123",
pass: "your-credential-password",
},
});
await transporter.sendMail({
from: "noreply@yourdomain.com",
to: "recipient@example.com",
subject: "Hello",
text: "Hello from Cosmoner!",
});
```
## Send Limits
Each project is on an email plan — Pay as you go, or a plan with emails
included — which sets the per-email rate and the daily and hourly limits. You
can check your plan, usage and limits:
```
GET /v1/projects/:projectId/email/limits
```
To change plan (owners and admins only, as it changes what the project is
billed):
```
PUT /v1/projects/:projectId/email/plan
```
The monthly cap is the most a month can send. On a plan it starts at the
included emails, and raising it allows the emails beyond them, billed at the
plan's rate. Like the plan, only owners and admins can change it:
```
PUT /v1/projects/:projectId/email/monthly-cap
```
If you need a higher hourly limit, request an increase:
```
POST /v1/projects/:projectId/email/limit-increase
```
Limit increase requests are reviewed by the Cosmoner team. You'll receive a notification when your request is approved or rejected.
## Sending Reputation
Mailbox providers judge every sender by how much of its mail bounces and how
much recipients mark as spam. Cosmoner tracks both rates for your project over
a trailing window, and shows them, with the thresholds your project is held
to, on the project's **Email** page:
* **Bounces** count mail sent to addresses that do not exist. An address that
has bounced is never sent to again, and those blocked sends do not count
against you.
* **Spam complaints** count mail a recipient reported as spam.
Rates are only judged once your project has sent enough mail in the window for
them to mean something. When either rate reaches its warning threshold, the
project's biller gets an email and a notification. When either reaches its
suspension threshold, email sending for the project is **suspended**: SMTP
refuses new messages with a permanent `550` error, and the API answers
`403 EMAIL_SENDING_SUSPENDED`. A suspension does not lift on its own —
[open a support ticket](/controlpanel/support/new) and tell us what was sent
and what you have changed.
To read the same figures programmatically:
```
GET /v1/projects/:projectId/email/reputation
```
To keep both rates low, only send to people who asked for your mail, remove
addresses that bounce, confirm new sign-ups before mailing them, and include a
working unsubscribe link in anything that is not a direct response to the
recipient. [Webhooks](/docs/guides/webhooks) deliver every bounce and complaint
as it happens, so you can act on them automatically.
## Managing Credentials
### List Credentials
Credentials are included when listing email domains:
```
GET /v1/projects/:projectId/email
```
Passwords are never returned after creation.
### Delete a Credential
```
DELETE /v1/projects/:projectId/email/:emailDomainId/credentials/:credentialId
```
Source: https://cosmoner.com/docs/guides/smtp-setup
---
# Email Templates
> Write transactional emails once in the control panel, in code with React Email or in the visual editor, publish them as versions, and send them by alias through the API.
## Overview
An email template is an email stored in your project: a subject, a body, and
the **variables** each send fills in. Your code names the template and passes
the values, and Cosmoner renders the email and sends it:
```json
{
"credentialId": "your-credential-id",
"to": "ada@example.com",
"template": {
"id": "order-receipt",
"variables": { "customer": "Ada", "orderNumber": "A-1042" }
}
}
```
Use a template rather than sending your own HTML when:
* **people other than developers change the wording.** Anyone with the
developer role can edit and publish a template in the control panel, with no
deploy of your application.
* **you want a history and a way back.** Every publish is a numbered version,
and rolling back takes effect on the next send.
* **you want the data checked.** A send whose variables do not match what the
template declares is refused before anything goes out, rather than delivered
with a blank where a name should be.
Sending your own `subject` and `html` still works exactly as before, and suits
one-off messages or emails you already render elsewhere. Templates are free:
emails sent from them are billed like any other, one email per recipient. See
[Email Pricing](/docs/pricing/email).
## Prerequisites
* A verified sending domain and an SMTP credential on it — see
[SMTP Setup](/docs/guides/smtp-setup). The credential decides the sender
address, for templates as for any other send.
* The developer role or above to create, edit and publish templates. Viewers
can open them read-only.
## Create a Template
Open **Email → Templates** in your project and choose **New template**. Give
it:
* **a name**, which is only for people;
* **an alias**, which is what your code sends by (see below);
* optionally **a folder**;
* **an editor**:
* **Visual** — you write the email like a document, inserting variables,
conditions, repeated blocks, buttons and images. You can convert it to code
later.
* **Code** — a [React Email](https://react.email) component in TSX, for full
control over the layout.
A new template starts from a short example you can replace. Nothing can be sent
from it until you [publish](#publish) it.
### Aliases
The alias is the stable handle your code names in `template.id`, such as
`order-receipt` or `password-reset`. It must:
* use only lower-case letters, digits, `-` and `_`;
* start with a letter or a digit;
* be at most 64 characters;
* be unique in the project.
You can change an alias under the template's **Settings** tab, but sends that
name the old alias are refused from that moment, so deploy the new name in your
code first. Every template also has an id that never changes; `template.id`
accepts either, and a send by id is not affected by an alias change.
## Variables
Variables are the values a send supplies. Each one has a name, a type, and
whether it is required.
| Type | A send passes | Example |
| ---------- | -------------------------------------------------------------- | ------------------------------------ |
| `string` | Text, up to 10,000 characters | `"Ada"` |
| `number` | A number | `2`, `19.99` |
| `boolean` | `true` or `false` | `true` |
| `url` | An `http`, `https` or `mailto` address, up to 2,048 characters | `"https://example.com/track/A-1042"` |
| `email` | An email address | `"ada@example.com"` |
| `datetime` | An ISO 8601 date and time with a time zone offset | `"2026-11-03T14:30:00Z"` |
| `list` | An array of items, each with the same declared fields | `[{ "name": "Lamp", "qty": 2 }]` |
**Required and optional.** A required variable must be in every send. An
optional one may be left out or sent as `null`; it then renders as empty, or as
its **default** when the template declares one.
**Lists.** A list declares the fields every item has, each with its own type and
whether it is required. Items are flat — a list cannot contain another list —
and a list holds at most 500 items, or fewer if the template sets its own
maximum.
**Names** start with a letter and use letters, digits and underscores, up to 64
characters. A few reserved words, such as `true`, `false`, `null` and `empty`,
cannot be used; the editor tells you if you pick one.
**What a send is checked against.** `template.variables` is checked against the
variables of the version being sent before anything goes out. A missing required
value, a value of the wrong type, and a name the template does not declare are
all refused — an unknown name is usually a typo, and would otherwise leave a gap
in a delivered email. See [Errors](#errors).
Every value is escaped as it is put into the email, so a variable is always
text: a customer whose name contains `` gets those characters, not bold type.
### In the Visual Editor
Declare variables on the **Variables & data** tab: give each a name, a type,
whether it is required and a sample value. For a list, add the fields its items
have.
In the email, type `/` to insert a block. Alongside text, headings, lists,
columns, buttons and dividers, the menu has the template blocks:
* **Variable** inserts a chip that is replaced by the send's value. Select a
chip to change its format: as sent, UPPERCASE, lowercase or Capitalized.
* **Link to a variable** makes a link whose address each send supplies. Only
`url` variables can be used, so the address is always a web link. A button can
point to a variable the same way.
* **Show if…** wraps a block that appears only when a variable has a value. The
block is left out when the variable is missing, empty, or `false`.
* **Repeat for each…** repeats a block for every item of a list variable.
Inside it, insert the item's fields as `item.field`.
* **Image** inserts an image already hosted on the web, by its `https://`
address, with alt text and an optional width and link.
The **Subject** field above the email takes variables too: write `{{ name }}`
where the value goes. **Preview text**, the line most inboxes show after the
subject, is optional.
### In the Code Editor
A code template is a React Email component that exports three things: its
`variables`, its `subject`, and the component itself as the default export.
```tsx
import { defineVariables, Each, If, v, Var, type VariablesOf } from "@cosmoner/email-kit";
import { Body, Button, Container, Html, Section, Tailwind, Text, pixelBasedPreset } from "react-email";
export const variables = defineVariables({
customer: v.string({ required: true, sample: "Ada" }),
orderNumber: v.string({ required: true, sample: "A-1042" }),
placedAt: v.datetime({ required: true }),
items: v.list(
{
name: v.string({ required: true, sample: "Desk lamp" }),
qty: v.number({ required: true, sample: 2 }),
},
{ maxItems: 100 },
),
trackingUrl: v.url(),
discountCode: v.string({ default: "none" }),
});
type Props = VariablesOf;
export const subject = (props: Props) => `Order ${props.orderNumber} confirmed`;
export default function OrderReceipt(props: Props) {
return (
Thanks {props.customer}, your order was placed on{" "}
.
{(item) => (
{item.name} × {item.qty}
)}
Discount code:
);
}
```
* **`defineVariables`** declares the variables with the builders `v.string`,
`v.number`, `v.boolean`, `v.url`, `v.email`, `v.datetime` and `v.list`. Each
takes `required`, `sample`, `default` and `description`; `v.list` takes its
item fields first, then `required`, `maxItems` and `sample`. `VariablesOf`
turns the declaration into your component's props.
* **`subject`** is a string, or a function of the props.
* **Render a value** as `{props.customer}`, or with **``** to format it:
`"upcase"`, `"downcase"`, `"capitalize"`, `{ date: "%b %d, %Y" }` (strftime
codes; dates are formatted in UTC, in English), `{ round: 2 }`,
`{ truncate: 80 }` or `{ default: "there" }`. `` names a
variable instead of taking a prop.
* **``** renders its children only when the variable has a value,
with an optional `fallback`. **``** repeats its children for every
item of a list. Lists cannot be nested.
* **Tailwind** classes inside `` are turned into inline styles, as
email clients need.
* **More than one file.** Add files under **Files** — up to 50 — and import them
from the entry file with a relative path such as `./components/footer`. A
template can import `react-email` (or `@react-email/components`),
`@cosmoner/email-kit` and its own files.
Values are filled in when each email is sent, not when you write it, so a
variable has to reach the email unchanged. Use ``, `` and ``
rather than JavaScript on a variable's value: calling `.toUpperCase()` or
`.map()` on it, testing it with `&&` or `.length`, putting it in a `style`
attribute or a `