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

Source: https://docs.quake.ai/resources/deployments/deploy-nextjs-app-template
Markdown: https://docs.quake.ai/resources/deployments/deploy-nextjs-app-template.md

---

# 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](/docs/platform/validation#how-infrastructure-templates-are-checked) `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.

<Figure size="md" caption="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">

```d2
direction: right

user: Visitor {shape: person}
domain: Your domain\n(DNS A record)
fip: Floating IP
instance: Ubuntu instance {
  caddy: Caddy reverse proxy\nHTTP + auto HTTPS
  app: Next.js container\nPORT 3000
  caddy -> app: forwards
}
pg: Postgres\n(self-managed template)

user -> domain: HTTPS
domain -> fip
fip -> instance.caddy
instance.app -> pg: DATABASE_URL
```

</Figure>

<PricingCompanion
  components={[
    { kind: "template", slug: "nextjs-app", required: true },
  ]}
/>

## Prerequisites

You need:

- Familiarity with Docker images and the command line. If containers are new to you, work through [Deploy a containerized web application](/docs/quickstart/deploy-containerized-app) 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](/docs/tools/openstack-cli).
- 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](/resources/iac-templates/nextjs-app), 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.



The default `flavor_name` is `s1a.small`. A standalone Next.js server runs comfortably there for a preview deploy. For a production deploy serving real traffic, set a larger flavor (for example `m2a.large`). See [Resource tiers](/docs/account/resource-tiers) for the options.



## 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](/docs/network/how-to/point-domain-to-quake-ai). Wait until the record resolves:

```bash
dig +short app.example.com
```

The command returns your floating IP once the record propagates.

2. Set `domain` in `terraform.tfvars`:

```hcl
domain = "app.example.com"
```

3. 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](/docs/network/how-to/lets-encrypt-certificate).

## Step 5: Connect a Postgres database

A Next.js app usually needs a database. Run one with the [self-managed Postgres template](/resources/iac-templates/self-managed-postgres), 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`.

2. 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
```

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



For the POC, `container_env` values pass to the container as `docker run -e` flags, so they appear in the instance process list and cloud-init logs. For a production deploy, source secrets from a secret store. See [How to store application secrets and inject them at runtime](/docs/security/how-to/inject-app-secrets).



## 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

- [How to deploy an application to a Quake AI VM from CI](/docs/automation/how-to/app-cicd-vm): build and push the image and run `tofu apply` from GitHub Actions or GitLab CI
- [How to use a container registry with Quake AI](/docs/kubernetes/how-to/use-container-registry): push images to GHCR, Docker Hub, or another registry the instance can pull from
- [self-managed Postgres template](/resources/iac-templates/self-managed-postgres): size and operate the database behind your app
- [Containerized app template](/resources/iac-templates/containerized-app): the general-purpose template the `nextjs-app` template specializes
- [Security hardening checklist](/docs/security/hardening-checklist): tighten SSH access and exposure before you serve real traffic

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