This article is the English translation version of the original Japanese article.
https://dev.classmethod.jp/articles/snowflake-try-appruntime-with-elementary-report/
I'm Sagara.
Previously, I wrote an article about building an environment where you can view Elementary reports on the web using Snowpark Container Services.
https://dev.classmethod.jp/articles/snowpark-container-services-for-elementary-report/
After that, I also wrote an article for teams using Databricks, where I tried doing the same thing with Databricks Apps.
https://dev.classmethod.jp/articles/databricks-apps-hosting-elementary/
In the SPCS version, I had to create a Dockerfile, build a Docker image, push it to an Image Repository, create a Compute Pool, and create a service using snow spcs service create. Since an Elementary report is essentially just static files named elementary_report.html and elementary_output.json, building and pushing my own container image honestly felt a bit excessive.
Meanwhile, Snowflake App Runtime, which allows you to deploy Node.js applications, mainly Next.js applications, directly on Snowflake, has been released in Public Preview.
https://docs.snowflake.com/en/developer-guide/snowflake-app-runtime/about-snowflake-app-runtime
https://zenn.dev/snowflakejp/articles/9e5b8fa393ccd8
With Snowflake App Runtime, you can deploy an application using only snow app deploy from Snowflake CLI, without using a Dockerfile, Docker image build, or Image Repository. In this article, I tried solving the same problem — hosting Elementary reports — with Snowflake App Runtime.
:::message
Snowflake App Runtime internally runs as an Application Service on Snowpark Container Services. In the title, “without Docker” means that users do not need to manage Dockerfiles or container images themselves. Please keep this point in mind.
:::
Feature overview
Snowflake App Runtime is a feature that allows you to deploy full-stack web applications written in Node.js, especially Next.js, directly within Snowflake’s security boundary. You can also query Snowflake tables directly from your application code without going through an API layer or ETL process.
The deployment flow is as follows: initialize the project with snow app setup, then run snow app deploy to upload, build, and deploy everything at once. Once deployment is complete, an authenticated URL like the following is issued, and users logged in to Snowflake can access the application directly from their browser.
https://<id>-<org>-<account>-<region>.snowflakecomputing.app/
Internally, it runs on Snowpark Container Services as a dedicated object type called Application Service. Since this is a different object type from traditional SPCS services, note that it does not appear in SHOW SERVICES; you need to check it with SHOW APPLICATION SERVICES.
Limitations
- As of July 7, 2026, this is a Public Preview feature. Specifications may change before GA.
- Trial accounts are not supported; a paid account is required.
- Snowflake CLI 3.17 or later is required. Older versions do not have the
snow app setupcommand. - During Public Preview, a managed Compute Pool is forcibly used.
- If you do not explicitly specify the database and schema when running
snow app setup, the application is deployed to a personal database by default, such asUSER$<username>. Applications deployed to a personal database cannot be shared with other roles. - Each deployment creates a new version instead of overwriting the existing version.
- Supported stacks are mainly Node.js/Next.js, and Python support is planned as of July 7, 2026.
Auto-suspend and cost considerations
Applications deployed with Snowflake App Runtime internally run as Application Services. Therefore, from a cost perspective, you need to be aware of the Application Service lifecycle.
Application Service has an auto-suspend setting called AUTO_SUSPEND_SECS, but the default value is 0, meaning auto-suspend is disabled. If you leave an application created for testing purposes running, compute costs may continue to be incurred unintentionally, so you should be careful.
AUTO_SUSPEND_SECS can be configured with CREATE APPLICATION SERVICE or ALTER APPLICATION SERVICE. However, as far as I checked on July 7, 2026, I could not find a way to specify it directly during deployment via snow app deploy options or snowflake.yml.
Therefore, configure it after deployment as needed, as shown below.
ALTER APPLICATION SERVICE
SAGARA_SNOWFLAKE_APPS.PUBLIC.ELEMENTARY_REPORT_APP
SET
AUTO_RESUME = TRUE,
AUTO_SUSPEND_SECS = 900;
In the example above, the Application Service is automatically suspended after 15 minutes of inactivity. The minimum non-zero value that can be specified for AUTO_SUSPEND_SECS is 300 seconds.
When an Application Service is suspended, its container stops and compute billing also stops. For test environments or internal applications that are used infrequently, it is safer to set AUTO_SUSPEND_SECS after running snow app deploy.
For reference, the Consumption Table listed the App Runtime unit price as shown below as of July 7, 2026.
Prerequisites
This validation assumes that the following have already been completed.
- Snowflake: A paid account, not a Trial account. The account must be able to use Snowflake App Runtime.
- Feature status: Public Preview as of July 7, 2026.
- Snowflake CLI: Version 3.17 or later is installed and can connect to the target account.
- Node.js: Installed for local testing.
-
Elementary report files:
elementary_report.htmlandelementary_output.jsonhave already been generated.
The Snowflake object names used in this article are standardized as follows.
| Object | Name |
|---|---|
| Database | SAGARA_SNOWFLAKE_APPS |
| Schema | PUBLIC |
| Warehouse | SAGARA_SNOWFLAKE_APPS_QUERY_WH |
| App name | elementary_report_app |
| Application Service | ELEMENTARY_REPORT_APP |
Preparation
Check Snowflake CLI version and connection
First, confirm that the Snowflake CLI version is 3.17 or later.
snow --version
Also check whether the snow app setup command exists. If you are using an older version, please upgrade it.
snow app setup --help
Next, check the connection to the target account.
snow connection test
If you use multiple connections, specify the connection name. Add --connection <connection_name> to the following commands as needed.
snow connection test --connection <connection_name>
If the connection information is displayed correctly, you are good to go.
Also check the Node.js version.
node --version
Check Elementary report files
This article assumes that the Elementary report has already been generated at the root of your dbt project.
dbt_project/
├── dbt_project.yml
├── models/
├── edr_target/
│ ├── elementary_report.html
│ └── elementary_output.json
What I tried
1. Create a directory for App Runtime
Create a local directory for App Runtime and copy the Elementary report files.
mkdir -p elementary-report-app/public
Rename elementary_report.html to index.html when copying it. This allows the Elementary report to be displayed simply by accessing the root URL of the application.
cp edr_target/elementary_report.html elementary-report-app/public/index.html
cp edr_target/elementary_output.json elementary-report-app/public/elementary_output.json
cd elementary-report-app
The final directory structure is as follows. We will create each file from here.
elementary-report-app/
├── app.yml
├── package.json
├── server.mjs
├── snowflake.yml # Generated by snow app setup
└── public/
├── index.html
└── elementary_output.json
2. Create server.mjs
Create server.mjs, which starts an HTTP server on Snowflake App Runtime. In the SPCS version, I served static files using an Nginx container, but this time I will use a minimal HTTP server built with Node.js.
Save the following content as elementary-report-app/server.mjs.
// server.mjs
import http from "node:http";
import { readFile, stat, readdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Use the App Runtime execution package root as the document root
const documentRoot = __dirname;
const port = Number(process.env.PORT || process.env.SERVER_PORT || 8080);
const host = "0.0.0.0";
const mimeTypes = {
".html": "text/html; charset=utf-8",
".json": "application/json; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".ico": "image/x-icon",
".map": "application/json; charset=utf-8",
".txt": "text/plain; charset=utf-8",
};
async function listFilesRecursive(dir, baseDir = dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(baseDir, fullPath);
if (entry.isDirectory()) {
const childFiles = await listFilesRecursive(fullPath, baseDir);
files.push(...childFiles);
} else {
files.push(relativePath);
}
}
return files;
}
function safeResolve(urlPath) {
const decodedPath = decodeURIComponent(urlPath.split("?")[0]);
// Return index.html for root access
const normalizedPath = decodedPath === "/" ? "/index.html" : decodedPath;
const filePath = path.normalize(path.join(documentRoot, normalizedPath));
// Prevent directory traversal
if (!filePath.startsWith(documentRoot)) {
return null;
}
return filePath;
}
const server = http.createServer(async (req, res) => {
try {
if (!req.url) {
res.writeHead(400, {
"content-type": "text/plain; charset=utf-8",
});
res.end("Bad Request");
return;
}
console.log(JSON.stringify({
message: "request",
url: req.url,
cwd: process.cwd(),
dirname: __dirname,
documentRoot,
}));
if (req.url === "/healthz") {
res.writeHead(200, {
"content-type": "text/plain; charset=utf-8",
});
res.end("ok");
return;
}
// For debugging: check the list of files available in the runtime environment
if (req.url === "/__debug/files") {
const files = await listFilesRecursive(documentRoot);
res.writeHead(200, {
"content-type": "application/json; charset=utf-8",
});
res.end(JSON.stringify({
cwd: process.cwd(),
dirname: __dirname,
documentRoot,
files,
}, null, 2));
return;
}
const filePath = safeResolve(req.url);
console.log(JSON.stringify({
message: "resolved file path",
url: req.url,
filePath,
}));
if (!filePath) {
res.writeHead(403, {
"content-type": "text/plain; charset=utf-8",
});
res.end("Forbidden");
return;
}
const fileStat = await stat(filePath);
if (!fileStat.isFile()) {
res.writeHead(404, {
"content-type": "text/plain; charset=utf-8",
});
res.end("Not Found");
return;
}
const body = await readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const contentType = mimeTypes[ext] || "application/octet-stream";
res.writeHead(200, {
"content-type": contentType,
"cache-control": "no-store",
});
res.end(body);
} catch (err) {
console.error(JSON.stringify({
message: "request failed",
url: req.url,
error: err instanceof Error ? err.message : String(err),
}));
res.writeHead(404, {
"content-type": "text/plain; charset=utf-8",
});
res.end("Not Found");
}
});
server.listen(port, host, () => {
console.log(JSON.stringify({
message: "Elementary report server started",
url: `http://${host}:${port}`,
cwd: process.cwd(),
dirname: __dirname,
documentRoot,
}));
});
The key points are as follows.
- It reads the port number from the
PORTorSERVER_PORTenvironment variable, and defaults to8080if neither is set. - It listens on
0.0.0.0so that it can receive requests from the Application Service. - It provides a
/healthzendpoint that can be used for health checks. - It includes directory traversal protection using
path.normalizeandstartsWith. - It does not depend on any external npm packages and uses only standard Node.js modules.
3. Create package.json
Next, create package.json.
{
"name": "elementary-report-app",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"start": "node server.mjs"
}
}
Since this configuration does not use any external npm packages, npm install and node_modules are basically unnecessary.
4. Create app.yml
app.yml is the file that defines the build, execution, and packaging behavior for App Runtime.
run:
command: [node, server.mjs]
artifacts:
- src: "server.mjs"
dest: "."
- src: "package.json"
dest: "."
- src: "public/index.html"
dest: "."
- src: "public/elementary_output.json"
dest: "."
profile:
label: "Elementary Report"
description: "Static hosting for Elementary report on Snowflake App Runtime"
Here, the following are defined.
-
run.command: The command executed when the application starts. -
artifacts: Files to include in the deployment target. This includesserver.mjs,package.json, and the Elementary report files underpublic/. -
profile: The application label and description.
Be careful that if the destination path of the files under public/ and the path referenced by server.mjs do not match, the Elementary report will return 404 after deployment. Details are described later in the “Issue I ran into” section.
5. Test locally
Before deploying to Snowflake, verify locally that server.mjs works as expected.
npm start
In another terminal, run a health check.
curl http://localhost:8080/healthz
If the following is returned, it is OK.
ok
Open http://localhost:8080 in your browser and confirm that the Elementary report screen is displayed.
Once confirmed, stop the server with Ctrl + C.
6. Create Snowflake objects
Create a Database and Warehouse for testing. Use PUBLIC as the Schema.
USE ROLE ACCOUNTADMIN;
CREATE DATABASE IF NOT EXISTS SAGARA_SNOWFLAKE_APPS;
CREATE WAREHOUSE IF NOT EXISTS SAGARA_SNOWFLAKE_APPS_QUERY_WH
WAREHOUSE_SIZE = XSMALL
AUTO_SUSPEND = 60
AUTO_RESUME = TRUE
INITIALLY_SUSPENDED = TRUE;
Normally, the PUBLIC schema is also created when the Database is created, but if you want to create it explicitly, run the following as well.
CREATE SCHEMA IF NOT EXISTS SAGARA_SNOWFLAKE_APPS.PUBLIC;
Grant privileges to the role that deploys the application as needed. For example, if deploying with the SYSADMIN role, run the following.
USE ROLE ACCOUNTADMIN;
GRANT USAGE ON WAREHOUSE SAGARA_SNOWFLAKE_APPS_QUERY_WH TO ROLE SYSADMIN;
GRANT USAGE ON DATABASE SAGARA_SNOWFLAKE_APPS TO ROLE SYSADMIN;
GRANT USAGE ON SCHEMA SAGARA_SNOWFLAKE_APPS.PUBLIC TO ROLE SYSADMIN;
GRANT CREATE APPLICATION SERVICE ON SCHEMA SAGARA_SNOWFLAKE_APPS.PUBLIC TO ROLE SYSADMIN;
GRANT CREATE STAGE ON SCHEMA SAGARA_SNOWFLAKE_APPS.PUBLIC TO ROLE SYSADMIN;
7. Set up Snowflake App Runtime
Create the configuration for App Runtime. By explicitly specifying the deployment destination, you can avoid deploying to a personal database.
snow app setup \
--app-name elementary_report_app \
--database SAGARA_SNOWFLAKE_APPS \
--schema PUBLIC \
--warehouse SAGARA_SNOWFLAKE_APPS_QUERY_WH
If you use multiple connections, specify the connection name.
snow app setup \
--connection <connection_name> \
--app-name elementary_report_app \
--database SAGARA_SNOWFLAKE_APPS \
--schema PUBLIC \
--warehouse SAGARA_SNOWFLAKE_APPS_QUERY_WH
After execution, snowflake.yml is generated automatically. The following is the content of the file generated when I ran the command.
definition_version: "2"
entities:
elementary_report_app:
type: snowflake-app
identifier:
name: ELEMENTARY_REPORT_APP
database: SAGARA_SNOWFLAKE_APPS
schema: PUBLIC
artifacts:
- src: ./*
dest: ./
ignore:
- node_modules
- .env*
- __pycache__
- "*.pyc"
- .next
- .git
- snowflake.log
query_warehouse: SAGARA_SNOWFLAKE_APPS_QUERY_WH
code_stage: ELEMENTARY_REPORT_APP_CODE
If database and schema are set to a personal database, such as USER$<username>, the application cannot be shared with your team. Explicitly specify the --database and --schema options and rerun snow app setup.
8. Deploy
Run snow app deploy to upload, build, and deploy to Snowflake all at once.
snow app deploy
If you want to explicitly specify the connection name, run the following.
snow app deploy --connection <connection_name>
Even if the log appears to be stuck at the same point, wait until a message like App ready at https://... is displayed.
After a few minutes, if the following is displayed, the deployment has succeeded.
9. Open the app and display the report
Once deployment is complete, open the application in your browser with snow app open.
snow app open
If you want to explicitly specify the connection name, run the following.
snow app open --connection <connection_name>
If you only want to display the URL, add --print-only. Since I tested this on Ubuntu 24.04 LTS running on WSL2, I ran the following command.
snow app open --print-only
Paste this URL into your browser, authenticate with Snowflake, and if the Elementary report is displayed in the browser, it is successful.
10. Share with other roles
If you want to share the application with team members, grant the USAGE privilege to the target role. In this example, I share it with the ANALYST role.
USE ROLE ACCOUNTADMIN;
GRANT USAGE ON DATABASE SAGARA_SNOWFLAKE_APPS TO ROLE ANALYST;
GRANT USAGE ON SCHEMA SAGARA_SNOWFLAKE_APPS.PUBLIC TO ROLE ANALYST;
GRANT USAGE ON APPLICATION SERVICE
SAGARA_SNOWFLAKE_APPS.PUBLIC.ELEMENTARY_REPORT_APP
TO ROLE ANALYST;
This allows users with the ANALYST role to access the App Runtime URL through Snowflake authentication.
11. Check the Application Service
The created Application Service can be checked with SHOW APPLICATION SERVICES, not SHOW SERVICES.
USE DATABASE SAGARA_SNOWFLAKE_APPS;
USE SCHEMA PUBLIC;
SHOW APPLICATION SERVICES;
If you want to filter only the target Application Service, run the following.
SHOW APPLICATION SERVICES LIKE 'ELEMENTARY_REPORT_APP';
If ELEMENTARY_REPORT_APP is displayed, it is OK.
12. Flow when updating the report
After regenerating the Elementary report, replace the files under public/ and redeploy.
If you are working from the root of the dbt project, run the following.
cp edr_target/elementary_report.html elementary-report-app/public/index.html
cp edr_target/elementary_output.json elementary-report-app/public/elementary_output.json
cd elementary-report-app
snow app deploy
If you are already in the elementary-report-app directory, run the following.
cp ../edr_target/elementary_report.html public/index.html
cp ../edr_target/elementary_output.json public/elementary_output.json
snow app deploy
Internally, a new version is created, and the Application Service reference is switched to the new version. As a result, you can access the latest Elementary report using the same URL. If these commands are incorporated into a CI/CD pipeline or executed after dbt runs, report updates could also be automated.
13. Stop the deployed app
Finally, I will also describe how to stop the App Runtime application after testing.
An application deployed with Snowflake App Runtime runs as an Application Service.
If it is no longer needed, you can delete the Application Service with snow app teardown.
snow app teardown --connection <connection_name>
If you want to skip the confirmation prompt, add --force.
snow app teardown --connection <connection_name> --force
This command deletes the target Application Service based on the current project definition, namely snowflake.yml inside the elementary-report-app directory.
Therefore, you should basically run it in the directory where snowflake.yml exists.
cd elementary-report-app
snow app teardown --connection <connection_name>
When you actually run it, it is displayed as shown below.
After deletion, you can confirm that the Application Service no longer exists with the following SQL.
USE DATABASE SAGARA_SNOWFLAKE_APPS;
USE SCHEMA PUBLIC;
SHOW APPLICATION SERVICES LIKE 'ELEMENTARY_REPORT_APP';
If ELEMENTARY_REPORT_APP is not displayed, the deletion is complete.
Note that snow app teardown deletes the Application Service and related files on the code stage, but it does not delete the Database or Warehouse created in this article.
If the Snowflake objects created for testing are also no longer needed, delete them separately as follows.
USE ROLE ACCOUNTADMIN;
DROP WAREHOUSE IF EXISTS SAGARA_SNOWFLAKE_APPS_QUERY_WH;
DROP DATABASE IF EXISTS SAGARA_SNOWFLAKE_APPS;
If you want to start the stopped application again, simply deploy it again from the same project directory.
snow app deploy --connection <connection_name>
Issue I ran into: Not Found after opening the URL post-deployment
This time, I encountered an issue where snow app deploy completed successfully and an App Runtime URL was issued, but opening the URL in the browser showed Not Found.
In the deployment log, public/index.html and public/elementary_output.json were uploaded to the stage as follows.
Uploaded public/elementary_output.json -> ...
Uploaded public/index.html -> ...
At first, I thought, “The files should have been uploaded, so why?” But the cause was that after being uploaded to the stage, the files did not exist at the path referenced by the Node.js server inside the App Runtime execution package.
Initially, I specified the following in app.yml.
artifacts:
- src: "public/**"
dest: "public"
On the other hand, server.mjs was implemented to reference public/index.html.
However, the files were not arranged as expected in the runtime environment, and as a result, index.html could not be found and Not Found was returned.
In the end, instead of preserving the public/ directory inside the execution package, I changed the configuration to place the required static files at the package root.
artifacts:
- src: "server.mjs"
dest: "."
- src: "package.json"
dest: "."
- src: "public/index.html"
dest: "."
- src: "public/elementary_output.json"
dest: "."
I also changed server.mjs to treat the execution package root as the document root.
const documentRoot = __dirname;
As a result, the files were arranged as follows on App Runtime, and index.html could be returned correctly from the root URL.
/
├── server.mjs
├── package.json
├── index.html
└── elementary_output.json
If snow app deploy succeeds but the screen shows Not Found, as in this case, it may not be an endpoint creation issue. Instead, the file placement in the App Runtime execution environment and the reference path in the application may be misaligned.
It was helpful to prepare /healthz and a simple debug endpoint that returns the list of files in the runtime environment, as it made it easier to identify the cause.
Comparison with the SPCS version and Databricks Apps version
Compared with the two configurations I tried previously, the differences are as follows.
| Item | SPCS version | Databricks Apps version | App Runtime version, this article |
|---|---|---|---|
| Static file serving | Nginx container | Python http.server
|
Minimal HTTP server with Node.js |
| Dockerfile | Required | Not required | Not required |
| Docker build | Required | Not required | Not required |
| Image Repository | Required | Not required | Not required |
| docker push | Required | Not required | Not required |
| Compute Pool | Created manually | Select Compute Size for Databricks Apps | Managed Pool handled by App Runtime |
| Sharing method | Grant to SERVICE ROLE | Grant CAN USE permission on Databricks Apps side |
Grant USAGE to Application Service |
The following tasks required in the SPCS version are no longer necessary in the App Runtime version.
- Creating a Dockerfile
- Configuring Nginx
- Building a Docker image
- Creating a Snowflake Image Repository
-
docker login/docker push - Creating a Compute Pool
- Creating a service spec YAML
-
snow spcs service create/snow spcs service list-endpoints
Instead, only the following files and commands are needed. Once the application files are ready, you can immediately deploy the app and share it with users in your Snowflake account. This is a major strength of Snowflake App Runtime!
server.mjspackage.jsonapp.yml-
snowflake.yml, generated bysnow app setup -
snow app setup/snow app deploy/snow app open
Conclusion
I tried hosting an Elementary report without Docker by using Snowflake App Runtime.
Compared with SPCS, Snowflake App Runtime removes the entire burden of container image management. For lightweight internal web publishing use cases like Elementary reports, Snowflake App Runtime looks like a very easy-to-use option. Since the specifications may change toward GA, I will continue to keep an eye on it!









Top comments (0)