Next.js App on Compute
Next.js app on compute
This pattern composes Compute and Network.
A validated OpenTofu template for quake.yaml manifests with runtime: container, shaped for Next.js. It provisions a CPU instance with Docker, runs a prebuilt Next.js image behind a Caddy reverse proxy, and publishes it on a floating IP with automatic HTTPS when you supply a domain.
What this template does#
Provisions a single compute instance for a containerized Next.js app:
- Private network, subnet, router, and neutron port
- Security group allowing SSH, the public HTTP port, and HTTPS (443)
- Volume-backed boot disk
- Floating IP for public access
- Cloud-init installs Docker, runs the Next.js container with
PORTset toapp_portand anycontainer_envvariables, and runs a Caddy reverse proxy that terminates TLS
A Next.js deploy is runtime: container with a Node Dockerfile. This template is the Next.js-shaped option alongside the generic containerized-app: it defaults app_port to 3000, adds the reverse proxy for TLS, and ships a documented standalone Dockerfile in the template's docker/ directory.
Parameters#
| Parameter | Description | Default |
|---|---|---|
key_name | SSH keypair name (must already exist in your project) | No default |
container_image | Prebuilt Next.js image to pull and run | registry.example.com/nextjs-app:latest |
flavor_name | Instance size | s1a.small |
image_name | Operating system image | Ubuntu-24.04 |
app_name | Resource name prefix | nextjs-app |
container_env | Environment variables injected into the container | {} |
app_port | Port the Next.js server listens on | 3000 |
host_port | Public HTTP port the reverse proxy listens on | 80 |
domain | Domain for automatic HTTPS (empty serves HTTP only) | "" |
external_network | External network for floating IP | PublicStatic |
private_cidr | Private subnet CIDR | 10.10.10.0/24 |
When to use this pattern#
Deploy a single VM that runs a containerized Next.js app with a TLS-ready reverse proxy. Build the app with output: "standalone", push the image to a registry, and set container_image. Put an edge reverse proxy in front when you need a dedicated entry point for one or more app hosts.
This is a single-VM origin, not a global edge deploy. There is no native CDN; put a third-party CDN in front of the floating IP for static and ISR assets if you need edge caching.
Estimated cost#
Monthly cost estimate
Pricing calculator ↗Sized as a custom package on shared vCPU.
Monthly total for the required template above. Use the configurator below to add optional pieces and see the total update.
What each resource is for
App tier
s1a.small · 2 shared vCPU, 2 GiB RAM, 0.5 Gbps
Compute shown per role at custom-package rates ($29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM). The headline above is the billed total: the cheaper of a named plan and the custom package, plus add-ons.
Included in baseline
s1a.small
2 shared vCPU, 2 GiB RAM, 0.5 Gbps
Compute + RAM rate basis
2 vCPU + 2 GiB RAM at $29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM (regular). Totals apply the flat −$5/mo package promotion.
Block storage (20 GiB)
20 GiB at $0.08/GiB/mo
Public IP (included)
1 included with the custom package
Package promotional discount
Flat −$5.00/mo on the custom package (same promotion as named plans).
Included at no charge
These line items are zero on Quake AI. Many other providers meter them separately.
Data transfer (inbound and outbound)
Unlimited data transfer on every plan; Quake AI does not meter per-GB egress.
AWS, GCP, and Azure meter outbound transfer per GB. DigitalOcean and Hetzner include an allowance on compute plans, then charge overage.
Learn morePrivate networking
Private networks, subnets, Neutron routers, and security groups are included with the plan.
VPC objects are usually free to create elsewhere, but NAT gateways bill hourly plus per-GB processed. Quake AI uses router SNAT with no separate NAT line item.
Control-plane API requests
OpenStack API calls for provisioning and management are included.
Some managed services on other clouds meter API calls or charge for premium control-plane features.
Pricing data last validated: . For current rates, check quake.ai/pricing.
Template source#
Show source (7 files)Hide source
locals {
# Render container_env into `-e KEY=VALUE` docker flags. Empty map is a no-op.
container_env_flags = join(
" ",
[for k, v in var.container_env : "-e ${k}=${v}"],
)
# With a domain, Caddy serves HTTPS with an automatic certificate. Without
# one, it serves plain HTTP on the public host port. Both reverse-proxy to the
# Next.js container bound to localhost.
caddy_site = var.domain != "" ? var.domain : ":${var.host_port}"
}
data "openstack_images_image_v2" "os" {
name = var.image_name
most_recent = true
}
data "openstack_networking_network_v2" "external" {
name = var.external_network
}
resource "openstack_networking_network_v2" "private" {
name = "${var.app_name}-net"
admin_state_up = true
}
resource "openstack_networking_subnet_v2" "private" {
name = "${var.app_name}-subnet"
network_id = openstack_networking_network_v2.private.id
cidr = var.private_cidr
ip_version = 4
dns_nameservers = ["1.1.1.1", "8.8.8.8"]
}
resource "openstack_networking_router_v2" "main" {
name = "${var.app_name}-router"
external_network_id = data.openstack_networking_network_v2.external.id
}
resource "openstack_networking_router_interface_v2" "private" {
router_id = openstack_networking_router_v2.main.id
subnet_id = openstack_networking_subnet_v2.private.id
}
resource "openstack_networking_secgroup_v2" "app" {
name = "${var.app_name}-sg"
description = "Allow SSH and HTTP/HTTPS traffic for the Next.js reverse proxy"
}
resource "openstack_networking_secgroup_rule_v2" "ssh" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 22
port_range_max = 22
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.app.id
}
resource "openstack_networking_secgroup_rule_v2" "http" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = var.host_port
port_range_max = var.host_port
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.app.id
}
resource "openstack_networking_secgroup_rule_v2" "https" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 443
port_range_max = 443
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.app.id
}
resource "openstack_networking_port_v2" "app" {
name = "${var.app_name}-port"
network_id = openstack_networking_network_v2.private.id
security_group_ids = [openstack_networking_secgroup_v2.app.id]
fixed_ip {
subnet_id = openstack_networking_subnet_v2.private.id
}
depends_on = [openstack_networking_router_interface_v2.private]
}
resource "openstack_compute_instance_v2" "app" {
name = var.app_name
flavor_name = var.flavor_name
key_pair = var.key_name
user_data = templatefile("${path.module}/cloud-init/app.yaml", {
container_image = var.container_image
app_port = var.app_port
host_port = var.host_port
env_flags = local.container_env_flags
caddy_site = local.caddy_site
})
block_device {
uuid = data.openstack_images_image_v2.os.id
source_type = "image"
destination_type = "volume"
volume_size = 20
boot_index = 0
delete_on_termination = true
}
network {
port = openstack_networking_port_v2.app.id
}
}
resource "openstack_networking_floatingip_v2" "app" {
pool = var.external_network
}
resource "openstack_networking_floatingip_associate_v2" "app" {
floating_ip = openstack_networking_floatingip_v2.app.address
port_id = openstack_networking_port_v2.app.id
}
variable "flavor_name" {
description = "Instance size (maps from quake.yaml environments.*.resources flavor alias)"
type = string
default = "s1a.small"
}
variable "image_name" {
description = "Operating system image"
type = string
default = "Ubuntu-24.04"
}
variable "key_name" {
description = "SSH keypair name (must already exist in your project)"
type = string
}
variable "app_name" {
description = "Display name prefix for compute and network resources"
type = string
default = "nextjs-app"
}
variable "container_image" {
description = "Prebuilt Next.js container image to pull and run on first boot. Build it from the standalone Dockerfile in docker/ and push it to a registry the instance can reach."
type = string
default = "registry.example.com/nextjs-app:latest"
}
variable "container_env" {
description = "Environment variables injected into the container at boot, by name (maps from quake.yaml secrets and resolved service outputs, for example DATABASE_URL). Document names here, not secret values."
type = map(string)
default = {}
}
variable "app_port" {
description = "Port the Next.js server listens on inside the container (PORT). Next.js standalone defaults to 3000."
type = number
default = 3000
}
variable "host_port" {
description = "Public HTTP port the reverse proxy listens on. With a domain set, the proxy also serves HTTPS on 443."
type = number
default = 80
}
variable "domain" {
description = "Optional fully qualified domain name. When set, the reverse proxy obtains a certificate and serves HTTPS automatically. Point the domain's DNS A record at the floating IP before apply. Leave empty to serve HTTP only."
type = string
default = ""
}
variable "external_network" {
description = "Shared external network for router gateway and floating IPs; defaults to PublicStatic (persisted FIP / production pattern). Override with PublicEphemeral for ephemeral demos."
type = string
default = "PublicStatic"
}
variable "private_cidr" {
description = "CIDR for the private tenant network the instance lives in"
type = string
default = "10.10.10.0/24"
}
output "instance_id" {
description = "ID of the compute instance running the Next.js container"
value = openstack_compute_instance_v2.app.id
}
output "floating_ip" {
description = "Public floating IP address for the Next.js app"
value = openstack_networking_floatingip_v2.app.address
}
output "private_ip" {
description = "Private IP address of the instance"
value = openstack_compute_instance_v2.app.access_ip_v4
}
output "app_url" {
description = "URL for the running app. HTTPS on the domain when set, otherwise HTTP on the floating IP and host port."
value = var.domain != "" ? "https://${var.domain}" : (
var.host_port == 80
? "http://${openstack_networking_floatingip_v2.app.address}"
: "http://${openstack_networking_floatingip_v2.app.address}:${var.host_port}"
)
}
terraform {
required_version = ">= 1.6.0"
required_providers {
openstack = {
source = "terraform-provider-openstack/openstack"
version = "~> 2.0"
}
}
}
provider "openstack" {}
# Required: SSH keypair must already exist in your project
key_name = "YOUR_KEY_NAME"
# Required for a working deploy: your prebuilt Next.js image (see docker/Dockerfile)
container_image = "registry.example.com/nextjs-app:latest"
# Optional: set a domain (with DNS pointed at the floating IP) for automatic HTTPS
# domain = "app.example.com"
# flavor_name = "s1a.small"
# image_name = "Ubuntu-24.04"
# app_name = "nextjs-app"
# app_port = 3000
# host_port = 80
# container_env = { DATABASE_URL = "postgres://...", NEXTAUTH_SECRET = "..." }
# external_network = "PublicStatic"
# private_cidr = "10.10.10.0/24"
#cloud-config
package_update: true
packages:
- ca-certificates
- curl
write_files:
- path: /etc/caddy/Caddyfile
content: |
${caddy_site} {
reverse_proxy app:${app_port}
}
runcmd:
- curl -fsSL https://get.docker.com | sh
- systemctl enable --now docker
- docker network create appnet
- docker pull ${container_image}
- docker run -d --name app --restart unless-stopped --network appnet -e PORT=${app_port} -e HOSTNAME=0.0.0.0 ${env_flags} ${container_image}
- docker run -d --name caddy --restart unless-stopped --network appnet -p ${host_port}:${host_port} -p 443:443 -v /etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro -v caddy_data:/data caddy:2-alpine
# Next.js App
Single compute instance running a containerized Next.js app behind a Caddy reverse proxy, with a floating IP and automatic HTTPS when you supply a domain. Specialization of `containerized-app` with Next.js-shaped defaults (`app_port` 3000, a reverse proxy for TLS, and a documented standalone Dockerfile).
**Network class:** production — `external_network` defaults to `PublicStatic` for persisted floating IPs and multi-tier stacks; override with `PublicEphemeral` for ephemeral demos.
## quake.yaml mapping
A Next.js deploy is `runtime: container` with a Node Dockerfile. When a launch manifest declares a Next.js app, the handoff resolves it to a container runtime template; this template is the Next.js-shaped option alongside the generic `containerized-app`.
| `quake.yaml` field | Maps to |
| --- | --- |
| `runtime: container` | Container runtime template (`nextjs-app` or `containerized-app`) |
| `source.build: dockerfile` | Build the standalone image in CI (see `docker/Dockerfile`), push to a registry, then set `container_image` before apply |
| `environments.production.resources: cpu-standard` | `flavor_name = "m2a.large"` |
| `environments.preview.resources: cpu-small` | `flavor_name = "s1a.small"` |
| `secrets` and resolved `services` outputs | `container_env` map injected into the running container |
## Prerequisites
- OpenTofu >= 1.6.0 or Terraform >= 1.6.0
- Quake AI account with OpenStack credentials
- An existing SSH keypair in your project (the value of `key_name` must match that keypair)
- A prebuilt Next.js container image reachable from the instance on first boot (public registry or project-accessible registry)
## Build the image
The app must be built with `output: "standalone"` in `next.config.js`. The included `docker/Dockerfile` is a multi-stage build that produces a minimal runtime image:
```bash
docker build -t registry.example.com/nextjs-app:latest -f docker/Dockerfile .
docker push registry.example.com/nextjs-app:latest
```
Set `container_image` to that reference before apply.
## Usage
1. Clone or copy this template directory
2. Copy `terraform.tfvars.example` to `terraform.tfvars` and fill in your values
3. Source your OpenStack credentials: `source openrc.sh`
4. Initialize: `tofu init`
5. Preview: `tofu plan`
6. Apply: `tofu apply`
After apply, open `app_url` from the outputs. For HTTPS, set `domain` and point its DNS A record at `floating_ip` before apply so Caddy can obtain a certificate.
## How the instance runs the app
cloud-init installs Docker, then runs two containers on a private Docker network:
- `app` runs your `container_image` with `PORT` set to `app_port` (default 3000), plus any `container_env` variables.
- `caddy` (Caddy) is the reverse proxy. With `domain` set it serves HTTPS with an automatic certificate; with `domain` empty it serves HTTP on `host_port`. It forwards traffic to the app container.
## Variables
| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `key_name` | string | yes | n/a | SSH keypair name (must already exist in your project) |
| `container_image` | string | no | `registry.example.com/nextjs-app:latest` | Prebuilt Next.js image to pull and run (replace with your own) |
| `flavor_name` | string | no | `s1a.small` | Instance size |
| `image_name` | string | no | `Ubuntu-24.04` | Operating system image |
| `app_name` | string | no | `nextjs-app` | Display name prefix for resources |
| `container_env` | map(string) | no | `{}` | Environment variables injected into the container at boot (names, not secret values) |
| `app_port` | number | no | `3000` | Port the Next.js server listens on inside the container |
| `host_port` | number | no | `80` | Public HTTP port the reverse proxy listens on |
| `domain` | string | no | `""` | Domain for automatic HTTPS; leave empty for HTTP only |
| `external_network` | string | no | `PublicStatic` | Persisted FIP / production default; override with `PublicEphemeral` for demos |
| `private_cidr` | string | no | `10.10.10.0/24` | CIDR for the private subnet |
## Outputs
| Name | Description |
| --- | --- |
| `floating_ip` | Public floating IP assigned to the instance |
| `private_ip` | Private IP address of the instance |
| `app_url` | HTTPS on the domain when set, otherwise HTTP on the floating IP and host port |
| `instance_id` | Compute instance ID |
## Environment variables and composition
`container_env` injects values into the container at boot. The launch handoff plan populates it from two sources: `quake.yaml` secret names (you supply the values) and the outputs of service templates applied earlier in the plan (for example a Postgres `DATABASE_URL`). Apply service templates first, read their outputs, then set `container_env` before applying this template.
For the POC, `container_env` values are passed as `docker run -e` flags and are visible in the instance process list and cloud-init logs. For production, source secrets from a secret store rather than the manifest.
## Scope
This is a single-VM origin, not a global edge deploy. There is no native CDN; bring a third-party CDN in front of the floating IP for static and ISR assets if you need edge caching.
## Documentation
See also: [Containerized app template](/resources/iac-templates/containerized-app)
Resources, parameters, and variables
flavor_name="s1a.small"image_name="Ubuntu-24.04"key_namerequiredapp_name="nextjs-app"container_image="registry.example.com/nextjs-app:latest"container_env={}app_port=3000host_port=80domain=""external_network="PublicStatic"private_cidr="10.10.10.0/24"
quake.yaml mapping#
| Manifest field | Template parameter |
|---|---|
runtime: container | Resolves to a container runtime template (nextjs-app or containerized-app) |
environments.production.resources: cpu-standard | flavor_name = "m2a.large" |
environments.preview.resources: cpu-small | flavor_name = "s1a.small" |
source.build: dockerfile | Build the standalone image in CI, push to a registry, set container_image before apply |
secrets and resolved services outputs | container_env map injected into the running container |
The launch handoff plan populates container_env from quake.yaml secret names (you supply the values) and from the outputs of service templates applied earlier in the plan, such as a Postgres DATABASE_URL. For the POC, container_env values pass as docker run -e flags, which are visible in the instance process list. Source production secrets from a secret store.
Outputs#
| Output | Description |
|---|---|
floating_ip | Public floating IP assigned to the instance |
private_ip | Private IP address of the instance |
app_url | HTTPS on the domain when set, otherwise HTTP on the floating IP and host port |
instance_id | Compute instance ID |
Read app_url after apply to verify the deployment. For HTTPS, set domain and point its DNS A record at floating_ip before apply so Caddy can obtain a certificate.
Customize this pattern#
- Customize a template's image and flavor
- Add a block volume to a template
- Parameterize a template with a tfvars file
See also#
Usage Guidelines
The sample code, software libraries, command line tools, proofs of concept, templates, and other related technology on this page (including any of the foregoing that is provided by Quake AI personnel) is provided to you as Quake AI Content under the Quake AI Customer Agreement, or the relevant written agreement between you and Quake AI (whichever applies). Do not use this Quake AI Content in your production accounts, or on production or other critical data. You are responsible for testing, securing, and optimizing the Quake AI Content (such as sample code) as appropriate for production grade use based on your specific quality control practices and standards. Deploying Quake AI Content may incur Quake AI charges for creating or using Quake AI chargeable resources, such as running Compute instances or storing data in Object Storage. Your use is also subject to the Acceptable Use Policy.
For the full policy, see Usage Guidelines.
Last validated: 29.06.2026
See Also
Containerized App on Compute
Shares: Key Pairs, Docker
Migrate a Docker container app from AWS to Quake AI
Shares: Docker, Containers
Deploy self-hosted Supabase with Docker Compose
Shares: Docker, Containers
How to Deploy a Containerized Application with Docker Compose on a Quake AI VM
Shares: Docker, Containers
Simple VM with Floating IP
Shares: Key Pairs, Security Groups