Back to Blog
Tutorial

Deploy a Full-Stack Application from Scratch in 15 Minutes

Daniel Brooks12 min read
Deploy a Full-Stack Application from Scratch in 15 Minutes

Deploying a frontend is easy. Deploying a backend is easy. Deploying both together with a database and having everything actually work in production — that's where most tutorials fall short.

They show you how to get a React app online, or how to run an Express server, but they stop before the part that matters: the frontend talking to the backend, the backend reading from a real database, and all of it staying up under real traffic.

This tutorial covers a complete full-stack deployment. You'll build a React frontend, an Express.js API, connect them to a PostgreSQL database, and deploy the entire stack as a single application on Out Plane. Start to finish: 15 minutes.

What You'll Build

A working full-stack application with three distinct layers:

  • Frontend: React app built with Vite, served from the production URL
  • Backend: Express.js API handling data operations and serving the React build
  • Database: PostgreSQL 17 managing persistent data

The architecture looks like this:

Browser
  └── your-app.outplane.app
        └── Express.js (port 8080)
              ├── /api/*        → Node.js route handlers
              │     └── Pool   → PostgreSQL (managed, Out Plane)
              └── /*            → React build (static files)

One URL. One deployment. Everything connected.

The key design decision here is monorepo-style: the Express server both handles API requests and serves the compiled React build as static files. This means you deploy one Docker container and get a complete application. No separate frontend deployment, no CORS configuration between services, no extra infrastructure to manage.

Prerequisites

Before you start, make sure you have:

  • Node.js 20+ installed locally (node --version to check)
  • Git installed and a GitHub account
  • Out Plane account — free at console.outplane.com

That's it. No cloud provider accounts, no Kubernetes, no DevOps background required.

Step 1: Build the Backend API

Start by creating your project directory and setting up the Node.js server.

bash
mkdir fullstack-app
cd fullstack-app
npm init -y
npm install express pg cors

Create server.js in the project root:

javascript
const express = require('express');
const { Pool } = require('pg');
const cors = require('cors');
const path = require('path');

const app = express();
const PORT = process.env.PORT || 8080;

// PostgreSQL connection pool
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  ssl: process.env.NODE_ENV === 'production'
    ? { rejectUnauthorized: false }
    : false,
});

app.use(cors());
app.use(express.json());

// Health check — Out Plane uses this to verify the app is running
app.get('/health', async (req, res) => {
  try {
    await pool.query('SELECT 1');
    res.json({ status: 'ok', db: 'connected' });
  } catch (err) {
    res.status(500).json({ status: 'error', db: 'disconnected' });
  }
});

// GET /api/items — list all items
app.get('/api/items', async (req, res) => {
  try {
    const result = await pool.query(
      'SELECT id, name, created_at FROM items ORDER BY created_at DESC'
    );
    res.json(result.rows);
  } catch (err) {
    console.error('GET /api/items error:', err.message);
    res.status(500).json({ error: 'Failed to fetch items' });
  }
});

// POST /api/items — create a new item
app.post('/api/items', async (req, res) => {
  const { name } = req.body;

  if (!name || name.trim().length === 0) {
    return res.status(400).json({ error: 'name is required' });
  }

  try {
    const result = await pool.query(
      'INSERT INTO items (name) VALUES ($1) RETURNING id, name, created_at',
      [name.trim()]
    );
    res.status(201).json(result.rows[0]);
  } catch (err) {
    console.error('POST /api/items error:', err.message);
    res.status(500).json({ error: 'Failed to create item' });
  }
});

// DELETE /api/items/:id — remove an item
app.delete('/api/items/:id', async (req, res) => {
  const { id } = req.params;

  try {
    await pool.query('DELETE FROM items WHERE id = $1', [id]);
    res.status(204).send();
  } catch (err) {
    console.error('DELETE /api/items error:', err.message);
    res.status(500).json({ error: 'Failed to delete item' });
  }
});

// Serve React build in production
// This block must come AFTER all API routes
if (process.env.NODE_ENV === 'production') {
  app.use(express.static(path.join(__dirname, 'client/dist')));

  app.get('*', (req, res) => {
    res.sendFile(path.join(__dirname, 'client/dist/index.html'));
  });
}

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

A few things worth noting in this code:

The DATABASE_URL environment variable is the only database configuration the app needs. Out Plane injects this at runtime — you never hardcode connection strings.

The ssl option is conditional: disabled locally (where you likely run PostgreSQL without TLS), enabled in production. This prevents connection errors when Out Plane's managed PostgreSQL requires SSL.

The wildcard app.get('*') route — which serves index.html for all unmatched paths — is placed after all API routes. Order matters in Express. If you put the wildcard first, it will intercept your API calls and return HTML instead of JSON.

Step 2: Build the Frontend

Create the React app inside a client subdirectory:

bash
npm create vite@latest client -- --template react
cd client
npm install

The key piece is how the frontend calls the API. Open client/src/App.jsx and replace the contents:

jsx
import { useState, useEffect } from 'react';
import './App.css';

function App() {
  const [items, setItems] = useState([]);
  const [newItem, setNewItem] = useState('');
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchItems();
  }, []);

  async function fetchItems() {
    try {
      const res = await fetch('/api/items');
      if (!res.ok) throw new Error('Failed to load items');
      const data = await res.json();
      setItems(data);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }

  async function addItem(e) {
    e.preventDefault();
    if (!newItem.trim()) return;

    try {
      const res = await fetch('/api/items', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: newItem }),
      });
      if (!res.ok) throw new Error('Failed to add item');
      const created = await res.json();
      setItems([created, ...items]);
      setNewItem('');
    } catch (err) {
      setError(err.message);
    }
  }

  async function deleteItem(id) {
    try {
      await fetch(`/api/items/${id}`, { method: 'DELETE' });
      setItems(items.filter(item => item.id !== id));
    } catch (err) {
      setError(err.message);
    }
  }

  if (loading) return <p>Loading...</p>;

  return (
    <main>
      <h1>Items</h1>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      <form onSubmit={addItem}>
        <input
          value={newItem}
          onChange={(e) => setNewItem(e.target.value)}
          placeholder="Add item..."
        />
        <button type="submit">Add</button>
      </form>
      <ul>
        {items.map(item => (
          <li key={item.id}>
            {item.name}
            <button onClick={() => deleteItem(item.id)}>Remove</button>
          </li>
        ))}
      </ul>
    </main>
  );
}

export default App;

Notice that every fetch() call uses a relative path like /api/items, not http://localhost:3001/api/items. This is intentional and important.

In development, you can configure Vite's proxy to forward /api requests to your local Express server. In production, there's no proxy needed — the Express server itself serves both the static files and the API from the same origin. Relative paths work in both environments without any code changes.

To configure the Vite proxy for local development, add this to client/vite.config.js:

javascript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:8080',
    },
  },
});

This way you can run node server.js in one terminal and npm run dev in the client directory in another, and both work independently during development.

Step 3: Connect Frontend and Backend

The connection between frontend and backend in production is already written in server.js. Let's revisit it clearly:

javascript
if (process.env.NODE_ENV === 'production') {
  app.use(express.static(path.join(__dirname, 'client/dist')));

  app.get('*', (req, res) => {
    res.sendFile(path.join(__dirname, 'client/dist/index.html'));
  });
}

When Vite builds the React app (npm run build), it outputs compiled, minified files to client/dist/. In production, Express serves those files as static assets. Any URL that doesn't match an API route returns index.html, letting React Router handle client-side navigation.

The result: one URL, one server process, one deployment. Your production URL your-app.outplane.app serves both the React app and the API.

Step 4: Add a Dockerfile

A Dockerfile defines exactly how your application gets built and run. Out Plane uses it to produce the container image that runs in production.

This is a multi-stage build, which keeps the final image small. The first stage installs all frontend dependencies and compiles the React app. The second stage sets up the Node.js server with only production dependencies, then copies the compiled frontend output into it.

Create Dockerfile in the project root:

dockerfile
# Stage 1: Build the React frontend
FROM node:20-alpine AS frontend
WORKDIR /app/client
COPY client/package*.json ./
RUN npm ci
COPY client/ .
RUN npm run build

# Stage 2: Build the production server
FROM node:20-alpine
WORKDIR /app

# Copy server dependencies and install
COPY package*.json ./
RUN npm ci --only=production

# Copy server source
COPY server.js ./

# Copy compiled frontend from Stage 1
COPY --from=frontend /app/client/dist ./client/dist

# Out Plane expects port 8080 by default
EXPOSE 8080

ENV NODE_ENV=production

CMD ["node", "server.js"]

Also create a .dockerignore file to exclude unnecessary files from the build context:

node_modules
client/node_modules
client/dist
.git
.env
*.log

The multi-stage approach matters for image size. If you installed all development dependencies (including Vite, React DevTools, and every build-time package) in the final image, you'd end up with hundreds of megabytes of packages that serve no purpose at runtime. The final image contains only what server.js actually needs: Express, the pg driver, and the compiled frontend files.

Step 5: Create the Database

Before deploying, you need a PostgreSQL database ready to accept connections.

Log in at console.outplane.com, navigate to Databases, and click Create Database.

Configure it:

  • Name: fullstack-app-db (or whatever identifies it for you)
  • Version: PostgreSQL 17
  • Region: Choose the region closest to your users

Click Create and wait about 30 seconds for provisioning. Once it's ready, Out Plane displays a connection URL in the format:

postgresql://user:password@host:5432/dbname

Copy this URL. You'll need it when setting environment variables in the next step.

Initialize the Schema

Your application expects an items table. Connect to your database using any PostgreSQL client (psql, TablePlus, pgAdmin) and run:

sql
CREATE TABLE items (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

If you don't have a local PostgreSQL client, you can run this migration as part of a startup script. Add an initDb call to server.js before app.listen:

javascript
async function initDb() {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS items (
      id SERIAL PRIMARY KEY,
      name VARCHAR(255) NOT NULL,
      created_at TIMESTAMP DEFAULT NOW()
    )
  `);
  console.log('Database initialized');
}

initDb()
  .then(() => {
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
  })
  .catch(err => {
    console.error('Failed to initialize database:', err.message);
    process.exit(1);
  });

Using CREATE TABLE IF NOT EXISTS makes this safe to run on every startup — it only creates the table if it doesn't already exist.

Step 6: Push to GitHub

Out Plane deploys from GitHub repositories. Create a repository on GitHub, then push your code:

bash
# From your project root
git init
git add .
git commit -m "Initial fullstack app"
git remote add origin https://github.com/your-username/fullstack-app.git
git push -u origin main

Make sure your .gitignore excludes node_modules, client/node_modules, client/dist, and any .env files before committing. Never commit your DATABASE_URL or other secrets to version control.

Your repository structure should look like this:

fullstack-app/
  ├── client/
  │   ├── src/
  │   │   └── App.jsx
  │   ├── vite.config.js
  │   └── package.json
  ├── server.js
  ├── package.json
  ├── Dockerfile
  └── .dockerignore

Step 7: Deploy on Out Plane

With your code on GitHub and your database provisioned, you're ready to deploy a full-stack application.

  1. Sign in at console.outplane.com
  2. Click New Application
  3. Select your GitHub repository (fullstack-app)
  4. Choose Dockerfile as the build method
  5. Set the port to 8080
  6. Under Environment Variables, click Add Variable:
    • Key: DATABASE_URL
    • Value: paste the PostgreSQL connection URL you copied earlier
  7. Click Deploy

Out Plane queues the build and moves through these stages:

Queued → Building → Deploying → Ready

Building is where Out Plane runs your Dockerfile. It executes both stages: building the React frontend with Vite, then assembling the production image with your compiled assets. You can watch the build logs in real time from the console.

Deploying is when Out Plane starts the container and waits for it to respond. This is where your initDb() function runs if you included it, creating the table on first boot.

Ready means your application is live and accepting traffic.

The entire process typically takes 2 to 4 minutes on the first deploy. Subsequent deploys are faster because layer caching reduces rebuild time for unchanged dependencies.

Your application is now accessible at https://your-app.outplane.app.

Step 8: Test Your Live Application

Visit https://your-app.outplane.app in a browser. The React app should load immediately.

Test the full stack manually:

  1. Frontend renders: The page loads without errors
  2. API responds: Visit https://your-app.outplane.app/api/items — you should see an empty JSON array []
  3. Health check passes: Visit https://your-app.outplane.app/health — you should see {"status":"ok","db":"connected"}
  4. Database writes: Add an item through the UI, then refresh. If the item persists after refresh, your database connection is working correctly

If the health check shows db: disconnected, check your DATABASE_URL environment variable in the Out Plane console. The most common issue is a copy-paste error in the connection string.

Step 9: Set Up Auto-Deploy

Every push to your configured branch triggers an automatic redeployment. This is enabled by default.

Your workflow from here:

bash
# Make a change locally, test it, then:
git add .
git commit -m "Update item deletion behavior"
git push origin main
# Out Plane detects the push, rebuilds, and deploys

Out Plane performs zero-downtime deployments. The new version builds while the current version continues serving traffic. When the new build is ready and passes health checks, traffic switches over. If the new build fails, the previous version stays live.

Production Improvements

Once your application is running, there are several things worth configuring before you treat it as production-ready.

Custom Domain

In the Out Plane console, navigate to your application's Domains settings. Add your custom domain, then update your DNS with the CNAME record Out Plane provides. SSL is provisioned and renewed automatically — no certificate management required.

Auto-Scaling

Under your application's Scaling settings, configure minimum and maximum instances. Out Plane scales your application based on traffic, spinning up additional containers when load increases and scaling back down during quiet periods. Per-second billing means you only pay for actual compute time, so idle containers cost nothing.

For a typical full-stack application, starting with a minimum of 1 instance and a maximum of 3 is reasonable. Adjust based on your traffic patterns.

Environment-Specific Configuration

If you maintain separate staging and production environments, create two Out Plane applications pointing to the same repository but different branches (e.g., main for production, staging for staging). Each application has its own environment variables, so you can connect staging to a separate database.

Error Handling and Logging

The basic error handling in this tutorial returns generic error messages. Before going live with real users, consider:

  • Structured logging (JSON format, so Out Plane's log viewer can parse fields)
  • Error tracking with a service like Sentry
  • Database connection retries with exponential backoff
  • Request validation with a library like Zod or Joi

These are not deployment concerns — they're application concerns. Out Plane gives you the logs, uptime monitoring, and metrics. What you do with application errors is up to your code.

Database Backups

Out Plane's managed PostgreSQL includes automatic backups. You can review backup settings and trigger manual snapshots from the Databases section of the console. For production data, verify your backup retention policy matches your recovery requirements.

Summary

You've built and deployed a complete full-stack application. Here's what the deployment covers:

  • A React frontend compiled with Vite and served as static files
  • An Express.js API with CRUD routes backed by PostgreSQL
  • A multi-stage Dockerfile that handles both frontend and backend builds
  • A managed PostgreSQL 17 database on Out Plane
  • Automatic SSL, zero-downtime deploys, and auto-scaling

Full-stack deployment does not require DevOps expertise. The Dockerfile handles the build complexity. The managed database eliminates database operations. Out Plane handles the infrastructure. Your job is to write application code.

The pattern you used here — one Express server handling both API routes and static file serving — scales well and stays simple. When your application grows to the point where you need separate frontend and backend deployments, Out Plane supports that too. But for most applications, a single deployment is the right starting point.

Ready to deploy? Start at console.outplane.com.


Related tutorials:


Tags

fullstack
deployment
react
nodejs
postgresql
tutorial

Start deploying in minutes

Connect your GitHub repository and deploy your first application today. $20 free credit. No credit card required.