Skip to content
Deployments

Deploy a Next.js app with the nextjs-app template

Deployment

Deploy a Next.js app with the nextjs-app template

Stand up a Next.js app on a single Quake AI instance using the validated OpenTofu template nextjs-app. You build a standalone container image, push it to a registry, apply the template, serve the app over a domain with HTTPS, and connect a Postgres database.

VisitorYour domain(DNS A record)Floating IPUbuntu instancePostgres(self-managed template)Caddy reverse proxyHTTP + auto HTTPSNext.js containerPORT 3000 forwardsHTTPSDATABASE_URL
Click to zoom
What you'll build: a Next.js container behind a Caddy reverse proxy on a single instance, reachable over a floating IP and a TLS-served domain, with a Postgres join point

Monthly cost estimate

Pricing calculator ↗

Sized as a custom package on shared vCPU.

Starting template$13.10/mo

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

$16.50/mo

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

$16.50

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

$1.60

Public IP (included)

1 included with the custom package

$0.00

Package promotional discount

Flat −$5.00/mo on the custom package (same promotion as named plans).

$-5.00

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 more
$0.00

Private 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.

$0.00

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.

$0.00

Pricing data last validated: . For current rates, check quake.ai/pricing.

Prerequisites#

You need:

  • Familiarity with Docker images and the command line. If containers are new to you, work through Deploy a containerized web application first.
  • OpenTofu 1.6.0 or later (or Terraform 1.6.0 or later) installed locally.
  • Your OpenStack credentials sourced into the shell (source openrc.sh). See the OpenStack CLI guide.
  • An SSH keypair that already exists in your project. Record its name for the key_name variable.
  • A container registry the instance can pull from on first boot (a public registry, or one your project can reach). Record the credentials if it is private.
  • A copy of the nextjs-app template directory from the template reference page, including its docker/Dockerfile.

A domain and a Postgres database are optional. You add the domain in step 4 and the database in step 5.

Step 1: Build a standalone Next.js image#

The template runs your app as a prebuilt container, so the image has to be self-contained. Next.js produces a minimal server bundle when you set output: "standalone".

Add the option to next.config.js:

JavaScript
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "standalone",
};

module.exports = nextConfig;

The template ships a multi-stage docker/Dockerfile that installs dependencies, builds the standalone output, and copies it into a small runtime image:

Dockerfile
# 1. Install dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci

# 2. Build the standalone output
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# 3. Minimal runtime image
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

RUN addgroup --system --gid 1001 nodejs \
  && adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

The runtime stage sets HOSTNAME=0.0.0.0 so the server binds on all interfaces inside the container, and PORT=3000 to match the template's app_port default. Build the image from your project root, tagging it for your registry:

bash
docker build -t REGISTRY_HOST/nextjs-app:latest -f docker/Dockerfile .

Run it locally to confirm it serves:

bash
docker run --rm -p 3000:3000 REGISTRY_HOST/nextjs-app:latest
curl -sS http://127.0.0.1:3000/ | head -n 5

You should see the start of your app's HTML. Stop the local container before continuing.

Step 2: Push the image and set the variables#

The instance pulls the image at first boot, so the image has to live in a registry it can reach. Log in and push:

bash
docker login REGISTRY_HOST
docker push REGISTRY_HOST/nextjs-app:latest

Copy the template's example variables file and open it:

bash
cp terraform.tfvars.example terraform.tfvars

Set the two required values. key_name is the SSH keypair already in your project, and container_image is the reference you pushed above:

HCL
key_name        = "YOUR_KEY_NAME"
container_image = "REGISTRY_HOST/nextjs-app:latest"

Leave domain commented out for now. You deploy over HTTP first, then add TLS in step 4.

Step 3: Apply the template and reach the app#

Initialize the working directory, preview the plan, and apply:

bash
tofu init
tofu plan
tofu apply

OpenTofu provisions a private network, a security group that allows SSH, HTTP, and HTTPS, an instance, and a floating IP. On first boot, cloud-init installs Docker and starts two containers on a private Docker network: your app on port 3000, and a Caddy reverse proxy in front of it.

When the apply finishes, read the outputs:

bash
tofu output

Record floating_ip, and open app_url in your browser or request it with curl:

bash
curl -sS http://YOUR_FLOATING_IP/ | head -n 5

Cloud-init pulls the image and starts the containers after the instance reaches ACTIVE, so allow a minute or two on the first boot. If the request fails, SSH in with ssh ubuntu@YOUR_FLOATING_IP and check sudo docker ps and sudo docker logs app.

Step 4: Serve the app over HTTPS with your domain#

Caddy obtains and renews a TLS certificate automatically once a domain resolves to the instance. Cloud-init runs on first boot only: changing domain in terraform.tfvars after the instance exists does not re-run the bootstrap script on a plain tofu apply.

Before the first apply: set domain in terraform.tfvars if you already know the hostname you will use, then run the initial tofu apply from Step 3 once with that value in place.

After the instance is already running: point DNS at the floating IP, set domain, then replace the instance so cloud-init runs again:

  1. Create a DNS A record for your domain (for example app.example.com) pointing at YOUR_FLOATING_IP. Follow How to point a domain at a Quake AI resource. Wait until the record resolves:
bash
dig +short app.example.com

The command returns your floating IP once the record propagates.

  1. Set domain in terraform.tfvars:
HCL
domain = "app.example.com"
  1. Taint the instance so the next apply recreates it and cloud-init runs again:
bash
tofu taint openstack_compute_instance_v2.app
tofu apply

Caddy requests a certificate from Let's Encrypt on the new boot and serves the app over HTTPS. The app_url output shows https://app.example.com. Open it in your browser to confirm the padlock. For background on how the certificate issuance and renewal work, see How to issue and auto-renew a TLS certificate with Let's Encrypt.

Step 5: Connect a Postgres database#

A Next.js app usually needs a database. Run one with the self-managed Postgres template, then inject its connection string into the app through the template's container_env map.

Each template provisions its own private network by default (nextjs-app uses 10.10.10.0/24; self-managed-postgres uses 192.168.40.0/24). The app reaches Postgres only after you allow the app subnet in Postgres security rules and connect the two networks at the router.

  1. Apply the Postgres template with allowed_cidrs set to the app subnet and assemble the connection string from its outputs:
HCL
# postgres/terraform.tfvars (excerpt)
allowed_cidrs = ["10.10.10.0/24"]

Read private_ip from the Postgres stack outputs and build postgres://USER:PASSWORD@POSTGRES_HOST:5432/DBNAME.

  1. Connect the app router to the Postgres private subnet (default resource names shown; adjust if you changed app_name or instance_name):
bash
openstack router add subnet nextjs-app-router postgres-private-sn
  1. Add the connection string to container_env in the nextjs-app stack before you apply it, or taint the app instance after you change the map:
HCL
container_env = {
  DATABASE_URL = "postgres://USER:PASSWORD@POSTGRES_HOST:5432/DBNAME"
}

If the app instance already exists, taint and recreate it so cloud-init picks up the new environment:

bash
tofu taint openstack_compute_instance_v2.app
tofu apply

Cloud-init starts the app container with DATABASE_URL set in its environment on that boot, where your Next.js data layer reads it.

What you built#

  • Built a standalone image from a multi-stage Dockerfile that bundles a Next.js server with output: "standalone"
  • Pushed the image to a registry the instance pulls from at first boot
  • Applied the nextjs-app template to provision a network, instance, security group, and floating IP, and reached the app over HTTP
  • Added HTTPS by pointing a domain at the floating IP and letting Caddy obtain a certificate
  • Connected Postgres by injecting DATABASE_URL through container_env

Scope of this deployment#

This template runs a single-VM origin, not a global edge deployment. The instance is CPU-only, and there is no native CDN. If you need edge caching for static or incrementally regenerated assets, put a third-party CDN in front of the floating IP. You operate the instance, the containers, and the database yourself.

Next steps#

Clean up#

When you no longer need the deployment, destroy everything the template created:

bash
tofu destroy

If you applied the Postgres template separately, run tofu destroy in that directory too. Then remove the DNS A record you created in step 4, and delete the image from your registry if you pushed it only for this walkthrough.

Before this

Quick answers

Was this page helpful?