@angular/ssr in your Git repository, runs ng build, and the web process runs the Express server that Angular builds, node dist/storefront/server/server.mjs. An Angular app without server-side rendering builds to static files only, and you can publish it as a static site instead.
At a glance
Environment variables
Before you begin
- A Sevalla company with a payment method, and a GitHub, GitLab or Bitbucket account for the repository. See Git providers.
- Node.js 24.15 or later on your computer. Angular 22 needs Node.js 22.22.3 or later in the 22 line, 24.15 or later in the 24 line, or 26.
Create the project
Create a workspace with server-side rendering:--ssr adds @angular/ssr and an Express server in src/server.ts. The other flags, and NG_CLI_ANALYTICS=false for the question about sharing usage data, answer the CLI’s questions, so the command runs without prompts and installs the dependencies with npm. The workspace comes with a .gitignore and a Git repository with a first commit.
Prepare the project for Sevalla
Render each request on the server
ng new sets every route to prerender: ng build renders the pages once, and the server sends the same HTML to everyone. To render each request on the server instead, set the render mode to RenderMode.Server in src/app/app.routes.server.ts:
RenderMode.Prerender for pages that are the same for every visitor; the server sends them as built.
Stop the server gracefully
When Sevalla replaces an instance, during a deployment for example, it sendsSIGTERM. Angular’s server doesn’t handle it, so requests in progress would be cut off. In src/server.ts, keep the server that app.listen() returns and close it on SIGTERM. Replace the block that starts the server with this:
server.close() stops accepting connections and lets the requests in progress finish, then Node.js exits.
Pin the Node.js version
start script runs ng serve, Angular’s development server, so Ingot ignores it and starts the server that ng build writes. It removes the devDependencies after the build; the server doesn’t need them, because ng build bundles Express and Angular into server.mjs.
Allow the domains and the proxy headers
Angular’s server answers only requests for host names it knows, to protect server-side requests from forgedHost headers. Any other host gets status 400 and Header "host" with value "<host>" is not allowed. You give it the list in the NG_ALLOWED_HOSTS environment variable.
It also ignores the X-Forwarded-For and X-Forwarded-Proto headers that Sevalla’s proxy adds to every request, and logs Received "x-forwarded-proto" header but "trustProxyHeaders" was not set up to allow it. for each one, unless you list them in NG_TRUST_PROXY_HEADERS. Trusting X-Forwarded-Proto also tells the server that the request came over HTTPS.
The deployment steps set both variables on Sevalla. To check the production build locally, set NG_ALLOWED_HOSTS to localhost:
http://localhost:4000. The Angular starter page shows Hello, storefront. Then push the repository that ng new created to your Git provider:
Deploy to Sevalla
These steps build with Ingot. To build from your own Dockerfile instead, see Deploy with a Dockerfile.Create the application
Add the environment variables
Deploy
Turn on auto-deploy
Verify the deployment
-
On the Deployments tab, click the deployment. Its Build log shows Ingot’s build plan: Node.js 24 from
engines.node, npm at the version inpackageManager, Angular with server-side rendering, and the server it starts.The plan’s warning aboutBuild logNG_ALLOWED_HOSTSshows on every build: Ingot readsangular.json, not the application’s environment variables. - Wait for the deployment’s status to change to Deployed. The application’s status next to its name changes to Running.
-
Click the domain under the application’s name, for example
storefront-x7k2m.sevalla.app. The Angular starter page opens with Hello, storefront. In the page source, the<h1>already holdsHello, storefrontand<app-root>hasng-server-context="ssr": the server rendered the page for this request.


Header "host" with value "storefront-x7k2m.sevalla.app" is not allowed., the domain isn’t in NG_ALLOWED_HOSTS: fix the value, then deploy again, because variables apply at the next deployment.
Deploy with a Dockerfile
Ingot needs no Dockerfile. Write one when you want full control of the image: the base image, the system packages and every layer. Add aDockerfile and a .dockerignore to the root of the repository:
Dockerfile and adds your .dockerignore patterns to its own, so you can keep both files in a repository that builds with Ingot. Then switch the application to the Dockerfile build strategy:
Choose the Dockerfile build strategy
Dockerfile and the Docker context ., then click Update build strategy.Deploy
📦 Building and pushing docker image using Dockerfile... instead of Ingot’s build plan, and its Dockerfile tab shows your file. NG_ALLOWED_HOSTS and NG_TRUST_PROXY_HEADERS still apply: the server reads them when it starts. When the status changes to Deployed, the domain opens the starter page with Hello, storefront, rendered by the server. See Dockerfile for build arguments and private base images.
Deploy changes
With Auto-deploy on, every push to the branch starts a deployment; otherwise, click Deploy now for each change. Each deployment builds the image, then replaces the web process’s instances, and the old instances finish their requests before they stop. See Deployments.Use environment variables
Add configuration on the application’s Environment variables tab. Code that runs on the server, such assrc/server.ts, reads it with process.env when the instance starts; variables apply at the next deployment. Code that runs in the browser can’t read the server’s environment. See Environment variables.
Add a custom domain
To serve the application from your own domain, such asshop.example.com, add the domain on the application’s Domains tab and create the DNS records that Sevalla shows. Custom domains need a web process larger than H1, so scale the web process first. Then add the domain to NG_ALLOWED_HOSTS, separated by a comma, for example storefront-x7k2m.sevalla.app,shop.example.com, and deploy. See Custom domains.
Deploy an Angular app without server-side rendering
An Angular app created without--ssr builds to HTML, CSS and JavaScript files only, and Sevalla can publish it as a static site, free up to 100 GB of bandwidth a month:
ng build writes the app to dist/storefront/browser. Push the repository to your Git provider, then create the static site:
Choose the repository
Set the build settings
Create the static site
index.html, so the Angular router renders its routes when someone opens or reloads them. Latest LTS meets Angular 22’s Node.js requirement; static sites don’t read engines in package.json. When the deployment’s status changes to Deployed, click Visit site on the static site’s Overview tab: the Angular starter page opens on the site’s *.sevalla.page domain. From a terminal, any path returns the app’s index.html, which the browser renders:
Deploy with the API
The Sevalla API makes the same deployment as Deploy to Sevalla without the dashboard. Run the steps in order in one shell: each step is one API call, and saves the IDs that later steps need in shell variables. Each step also names the CLI command and the MCP server tool that do the same. A variable that holdsnull means the call failed: run the curl command without its jq part to read the error.
This section has two sequences: the application with server-side rendering, and the static site of Deploy an Angular app without server-side rendering. You need:
- An API key with the Company developer role, or the
APP:CREATE,APP:READandAPP:UPDATEpermissions, in theSEVALLA_API_KEYenvironment variable. The CLI reads the key fromSEVALLA_API_TOKEN. - The repository on GitHub, GitLab or Bitbucket, and a Git account that can read it connected to your Sevalla user, which only the dashboard can do. See Git providers. Creating the application or the static site checks the access: if Sevalla can’t read the repository, the call returns
400withCannot access repository https://github.com/acme/storefront: Repository not found or you don't have access., followed by where to grant the access. curlandjq.
Deploy the application
Find the location
listClustersV3) returns every location with its id, its name in display_name and its region ID in location. Pick one by its region ID, here eu-frankfurt-1 for Frankfurt, Germany. Locations lists them all.sevalla resources clusters. MCP: get_platform_options.Create the application
createApplicationV3) creates the application with the Ingot build strategy and a web process, and doesn’t deploy it.sevalla apps create --name storefront --cluster "$CLUSTER_ID" --source privateGit --git-type github --repository https://github.com/acme/storefront --branch main. MCP: create_application with deploy set to false.Find the web process
listProcessesV3) returns the web process that Create application added:sevalla apps processes list --app-id "$APP_ID". MCP: get_application, in processes.Find the H1 size
listProcessResourceTypesV3) returns the ID of each size:sevalla resources process-types. MCP: get_platform_options.Set the web process size
updateProcessV3) sets the size, which applies at the first deployment:sevalla apps processes update "$WEB_ID" --app-id "$APP_ID" --resource-type "$H1_ID". MCP: scale_process with size_id, and apply_now set to false.Find the domain
listApplicationDomainsV3) returns the application’s domains. NG_ALLOWED_HOSTS needs the primary one. Sevalla adds the domain a few seconds after it creates the application, so the loop asks until the domain is there:sevalla apps domains list --app-id "$APP_ID". MCP: get_application, in domains.Add the environment variables
createEnvironmentVariableV3) adds one variable per call. The server reads both variables when it starts, so they need to be available at runtime only. The add_var function sends each one, with jq building the JSON body:sevalla apps env-vars create "$APP_ID" --key <key> --value <value> for each variable, which makes it available during both the build and runtime. MCP: set_environment_variables with all the variables in one call, runtime set to true and buildtime set to false.Turn on auto-deploy
updateApplicationV3) with auto_deploy deploys every push to the branch:sevalla apps update "$APP_ID" --data '{"auto_deploy":true}'. MCP: sevalla_api_update with updateApplicationV3, the application ID as path_params.id and the same body.Choose the Dockerfile build strategy
updateApplicationV3) sets the build strategy:sevalla apps update "$APP_ID" --data '{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}'. MCP: sevalla_api_update with updateApplicationV3 and the same body.Deploy
triggerDeploymentV3) deploys the latest commit of the branch:sevalla apps deployments trigger "$APP_ID". MCP: trigger_deployment.Wait for the deployment
getDeploymentV3) returns the deployment’s status: waiting, inProgress, then success, failed, cancelled or skipped. Poll it until the deployment ends:success means the application is deployed. CLI: sevalla apps deployments get "$DEPLOYMENT_ID" --app-id "$APP_ID". MCP: get_deployment.Read the build log
getApplicationDeploymentLogsV3) returns the build log, one line per entry. With Ingot, the Start row of the build plan shows the start command:📦 Building and pushing docker image using Dockerfile... in place of the build plan. When a deployment fails, the end of this log says why. CLI: sevalla apps deployments logs "$DEPLOYMENT_ID" --app-id "$APP_ID". MCP: get_deployment_logs.Check the application
ng-server-context="ssr" attribute that the server adds when it renders the page:Deploy the static site
The static site needs an API key with theSTATIC_SITE:CREATE, STATIC_SITE:READ and STATIC_SITE:UPDATE permissions, or the Company developer role.
Create the static site
createStaticSiteV3) creates the static site with the build settings of the dashboard steps. node_version lts is the dashboard’s Latest LTS. A static site has no location.sevalla static-sites create --name storefront --repository https://github.com/acme/storefront --branch main --git-type github --build-command "npm run build" --node-version lts --publish-directory dist/storefront/browser. The CLI has no options for the error file and auto-deploy, so set error_file and auto_deploy with the API. MCP: create_static_site with the same fields.Deploy the static site
deployStaticSiteV3) builds and publishes the latest commit of the branch. Unlike the dashboard, the API doesn’t start the first deployment when it creates the site.sevalla static-sites deployments trigger "$SITE_ID". MCP: trigger_deployment with service_type set to static_site.Wait for the static site deployment
getStaticSiteDeploymentV3) returns the deployment’s status. Poll it until the deployment ends:success means the site is live. If it’s failed, Get static site deployment logs (getStaticSiteDeploymentLogsV3) returns the build log. CLI: sevalla static-sites deployments get "$DEPLOYMENT_ID" --site-id "$SITE_ID". MCP: get_deployment, and get_deployment_logs for the log.Check the static site
getStaticSiteV3) returns the site’s domain in hostname, such as storefront-x7k2m.sevalla.page. The home page is the empty <app-root> element that the browser renders, and a path with no file gets the same index.html, the error file, so the router can render it:sevalla static-sites get "$SITE_ID". MCP: get_static_site.