From GitHub to production: deploy Next.js, PostgreSQL and a custom domain

Deploy a Next.js app and PostgreSQL on your own server with Zenavo, wire up private networking, build-time vs runtime variables, Prisma database migrations, HTTPS domains and push-to-deploy.

This post is also available in 简体中文, 繁體中文, 日本語, Español and Bahasa Indonesia.

Taking a Next.js app from localhost to production usually comes down to five things: the start script, the database connection, environment variables (knowing which run at build time vs runtime), database schema migrations, and an HTTPS domain.

This guide walks through deploying an existing Next.js repository alongside PostgreSQL on your own server. Before starting, connect a Linux server to Zenavo.

1. Check package.json in your repository

When a repository doesn't have a custom Dockerfile, Zenavo uses zbpack to detect and build Next.js automatically. Make sure your lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lockb) is committed and package.json has standard production scripts:

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "engines": {
    "node": ">=20"
  }
}

Run npm run build locally once before deploying so you catch TypeScript or build-time errors early.

2. Deploy PostgreSQL

Create a project in Zenavo, pick your server, then click Add Service → Database → PostgreSQL, choose a version and deploy.

The database runs on the project's private network and isn't exposed to the public internet by default. Once ready, it exports connection variables to other services in the same project; when the service is named postgresql, the full connection string is POSTGRESQL_CONNECTION_STRING.

3. Add the Next.js app from GitHub

In the same project, click Add Service → GitHub, grant the Zenavo GitHub App access to your repository, and select the repository, branch and root directory (for a monorepo where the app lives in apps/web, set the root directory to apps/web).

Reference the database in your environment variables:

DATABASE_URL=${POSTGRESQL_CONNECTION_STRING}

${POSTGRESQL_CONNECTION_STRING} is a cross-service variable reference in Zenavo and expands to the real private connection string at runtime (if you named the database service something else, use the variable name shown in the console).

By default, next start listens on the PORT environment variable provided by the platform. If you use a custom Dockerfile or custom server.js, make sure it listens on 0.0.0.0 and on the same HTTP port configured on the Zenavo service. See the Next.js guide for details.

4. NEXT_PUBLIC_* vs server-only variables

Next.js treats these two kinds of environment variables very differently:

  • Server-only variables (such as DATABASE_URL, payment secrets and AI API keys): read only in server code, and never given a NEXT_PUBLIC_ prefix. When you change them on the Variable tab in Zenavo, a simple service restart applies the new value.
  • NEXT_PUBLIC_* variables: inlined directly into the browser JavaScript bundle during next build. After changing any NEXT_PUBLIC_* variable, you must redeploy (rebuild) the service; restarting the existing container won't change the value baked into the client bundle.

Also note that Zenavo's isolated builders cannot reach your server's private database during next build. Because of that:

  • Don't run production database migrations inside your build script;
  • Pages that query the database should render dynamically at request time rather than statically pre-rendering during the build.

5. Run database migrations on startup (Prisma example)

Because the private database is only reachable when the container runs on your server, run schema migrations right before the app starts rather than during build.

When building with zbpack, add a predeploy script to package.json. Zenavo runs predeploy before start on each deployment:

{
  "scripts": {
    "build": "prisma generate && next build",
    "predeploy": "prisma migrate deploy",
    "start": "next start"
  }
}

Two things to keep in mind:

  • Always use prisma migrate deploy in production (which applies committed migration files), never prisma migrate dev or db push --force-reset.
  • If you build from your own Dockerfile, zbpack's predeploy hook isn't used — run npx prisma migrate deploy && npm start in your Dockerfile's CMD or ENTRYPOINT instead.

6. Bind a domain and enable push-to-deploy

Once the service is running, go to its Networking tab, bind your domain (such as app.example.com), point a DNS A record at your server's IP, and wait for the HTTPS certificate to be issued automatically (see Domains). If your app uses NextAuth / Auth.js, Clerk or OAuth providers, update NEXTAUTH_URL and your OAuth callback URLs to match the new domain.

Open the site, sign in, and create, read, update and delete a test record to confirm the app is reading and writing to PostgreSQL.

Under the service's Settings, enable Auto-deploy on push to this branch so every git push triggers a build and zero-downtime rollout. For monorepos, set watch paths so only changes inside that app's folder trigger a deploy (see Deploying from GitHub).

FAQ

The build fails with Can't reach database server at postgresql.zenavo.internal:5432? Your build script or a statically generated (SSG) page is trying to query the private database at build time. Move database migrations to predeploy (or your container startup command) and add export const dynamic = 'force-dynamic' to pages that read from the database.

I updated NEXT_PUBLIC_API_URL and restarted, but the browser still calls the old URL? NEXT_PUBLIC_* values are baked in at build time. Click Redeploy to run a fresh build with the new variable.

How do I roll back if a new deployment has a bug? You can roll back to any previous deployment image in one click from the deployment history. Keep in mind that service rollback restores the app image and environment variables, not the database. Before running destructive schema changes (like dropping a column), take a snapshot on the PostgreSQL service's Backup & Restore tab first.

Once your repository is ready, create a project in the Zenavo console: spin up PostgreSQL, connect your GitHub repo, set DATABASE_URL and bind your domain to go from git push to production in minutes.