DEV Community

Cover image for Deploy a Full Stack Next.js App to a DigitalOcean VPS With Docker
Andrew Shearer
Andrew Shearer

Posted on • Edited on

Deploy a Full Stack Next.js App to a DigitalOcean VPS With Docker

Project Info

In this tutorial, we will be creating a simple full stack note taking app using:

  • Next.js + Tailwind
  • Drizzle ORM
  • PostgreSQL
  • DBeaver
  • DigitalOcean
  • Docker

While the app itself is very simple, the main focus here is to learn how to:

  • Setup a PostgreSQL database locally
  • Deploy the app to a DigitalOcean VPS using Docker containers

The functionality of the app is:

  • Have a page to view all notes
  • On the same page, have form to create a note using server actions

So, not full CRUD, and no auth.

It’s a great pipeline to know how to navigate, but there will be some cost involved. I’ll try to keep costs as low as I can.

Let’s get started!

Setting Up PostgreSQL & DBeaver

Download page PostgreSQL here

Download page for DBeaver here

I am going to provide the steps I took to get PostgreSQL with DBeaver up and running locally, you might need to Google for your specific OS (I am on Ubuntu).

First, we need to install PostgreSQL:



sudo apt update
sudo apt install postgresql postgresql-contrib


Enter fullscreen mode Exit fullscreen mode

You can check the installation was successful by running:



psql --version
# psql (PostgreSQL) 16.0 (Ubuntu 16.0-1.pgdg22.04+1)


Enter fullscreen mode Exit fullscreen mode

During the installation, PostgreSQL will create a default user called postgres. You can set a password for this user by running this command:



sudo -u postgres psql postgres


Enter fullscreen mode Exit fullscreen mode

You’ll enter the postgres console. Once there, enter:



\password postgres


Enter fullscreen mode Exit fullscreen mode

Since this is just a local database to play around with, I'm not too concerned about security at this point. So for the local db, the username, password, and database name will all be postgres.

Enter a password, then enter this to quit the psql console:



\q
# or 
exit


Enter fullscreen mode Exit fullscreen mode

I will be using DBeaver as my database admin GUI of choice. It’s free, and available on all 3 major OS’s(Windows, Mac, & Linux). If you already have a tool you prefer to use, then of course stick with that.

Open DBeaver, and in the top menu bar, select Database, then New Database Connection

Settings:

  • Connect by Host
  • URL, Host, and Port can all be left alone
  • Database, username, and password should all be postgres

Click Test Connection

If you see a response like this:

DBeaver Local Postgres Connection Test

You’re good to go!

FYI: If you did not set a password for the default postgres user, the connection test would always fail.

Click Ok, then click Finish

Setup Next.js App

Then in a folder of your choice, download and setup the latest Next.js TypeScript starter:



npx create-next-app@latest --ts .


Enter fullscreen mode Exit fullscreen mode

If you are getting warnings in your CSS file complaining about unknown CSS rules, follow these steps here.

Still in globals.css, update the code with this reset from Josh Comeau.



/* src/app/globals.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

*, *::before, *::after {
  box-sizing: border-box;
}

* {
  margin: 0;
    padding: 0;
}

body {
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

img, picture, video, canvas, svg {
  display: block;
  max-width: 100%;
}

input, button, textarea, select {
  font: inherit;
}

p, h1, h2, h3, h4, h5, h6 {
  overflow-wrap: break-word;
}

#root, #__next {
  isolation: isolate;
}


Enter fullscreen mode Exit fullscreen mode

Update tsconfig.json to this:



{
  "compilerOptions": {
    "target": "esnext",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}


Enter fullscreen mode Exit fullscreen mode

Update src/app/page.tsx to this:



// src/app/page.tsx

const HomePage = () => {
  return (
    <div>
      <h1>HomePage</h1>
    </div>
  );
};

export default HomePage;


Enter fullscreen mode Exit fullscreen mode

Update src/app/layout.tsx to this:



import { Inter } from "next/font/google";

import type { Metadata } from "next";
import type { ReactNode } from "react";

import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  description: "Generated by create next app",
  title: "VPS Demo"
};

type RootLayoutProps = {
  children: ReactNode;
};

const RootLayout = ({ children }: RootLayoutProps) => {
  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
    </html>
  );
};

export default RootLayout;


Enter fullscreen mode Exit fullscreen mode

Setup GitHub Repo

I’m a fan of setting up a GitHub repo early and committing + pushing often in smaller chunks.

I’m going to assume that you know how to do that, so please do so before continuing.

Setup Drizzle

Link to Drizzle ORM docs here

Install the necessary packages:



npm i drizzle-orm postgres dotenv
npm i -D drizzle-kit


Enter fullscreen mode Exit fullscreen mode

At the root level of the project, create a file called drizzle.config.ts

Add this code to it:



// drizzle.config.ts

import dotenv from "dotenv";
import type { Config } from "drizzle-kit";

// this is needed for pushing
dotenv.config({ path: ".env.local" });

const config: Config = {
  schema: "./src/drizzle/schema.ts",
  out: "./src/drizzle",
  driver: "pg",
  dbCredentials: {
    connectionString: process.env.DATABASE_URL!
  }
};

export default config;


Enter fullscreen mode Exit fullscreen mode

We don’t have this drizzle folder and schema.ts yet, so create them at the specified location.

The schema.ts file can be blank for now.

We also don’t have an environment variable called DATABASE_URL yet either, so also at the root level, create a .env.local file.

Then in this file, add this:



DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"


Enter fullscreen mode Exit fullscreen mode

Next, in the drizzle folder, create a file called config.ts

Add this code to it:



// src/drizzle/config.ts

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

import * as schema from "@/drizzle/schema";

const connectionString = process.env.DATABASE_URL || "";
const client = postgres(connectionString);
const db = drizzle(client, { schema });

export { client, db };


Enter fullscreen mode Exit fullscreen mode

At this point, you should get a red underline for import * as schema, which makes sense since the file is currently blank. Let’s create a note table in the schema.

A note will have:

  • id (uuid)
  • name (text)
  • content (text)
  • color (text - will select from yellow, green, blue, purple in the ui)
  • created_at (timestamp)

Back in schema.ts, add the following:



// src/drizzle/schema.ts

import { timestamp, pgTable, text, uuid } from "drizzle-orm/pg-core";

export const notes = pgTable("notes", {
  id: uuid("id").defaultRandom().notNull().primaryKey(),
  name: text("name").notNull(),
  content: text("content").notNull(),
  color: text("color").notNull(),
  created_at: timestamp("created_at").notNull().defaultNow()
});


Enter fullscreen mode Exit fullscreen mode

Migrate & Push

Link to Migrations in the Drizzle docs here

Now that we have a model in our schema, we can migrate our database to your schema.

Add a generate script to package.json:



{
  // ...
  "scripts": {
    // ...
    "generate": "drizzle-kit generate:pg"
  }, 
}


Enter fullscreen mode Exit fullscreen mode

Then in the terminal, run:



npm run generate


Enter fullscreen mode Exit fullscreen mode

We can also push our schema changes directly to the database.

Add a push script to package.json:



{
  // ...
  "scripts": {
    // ...
    "push": "drizzle-kit push:pg"
  }, 
}


Enter fullscreen mode Exit fullscreen mode

Then run the command:



npm run push


Enter fullscreen mode Exit fullscreen mode

If the push was successful, you should now see a notes table in DBeaver!

Notes table appearing in local postgres db

And if you double click on it, and switch to the Data tab, you can see the table!

Viewing the notes table in local postgres database

It’s empty for now, but that’s ok! This is a great start!

Pro-tip: create a single db command to run the generate and push commands one after the other:



"scripts": {
  // ...
  "generate": "drizzle-kit generate:pg",
  "push": "drizzle-kit push:pg",
  "db": "npm run generate && npm run push"
},


Enter fullscreen mode Exit fullscreen mode

Setting Up Site Structure

For this simple app, we will have the following pages:

  • home
  • notes

You can copy and paste these commands to create all the files and folders at once:



cd src/app/
mkdir notes
cd notes/
touch page.tsx
cd ../../../


Enter fullscreen mode Exit fullscreen mode

Here’s the boilerplate code for the page.tsx:



// src/app/notes/page.tsx

const NotesPage = () => {
  return (
    <div>
      <h1>NotesPage</h1>
    </div>
  );
};

export default NotesPage;


Enter fullscreen mode Exit fullscreen mode

Next, create a folder called components in the src folder.

In the components folder, create a file called navbar.tsx and add the following code to it:



// src/components/navbar.tsx

import Link from "next/link";

const Navbar = () => {
  return (
    <nav className="border-b-black border-b-2 p-2">
      <ul className="flex items-center gap-x-4">
        <li>
          <Link className="hover:text-sky-500 hover:underline" href="/">
            Home
          </Link>
        </li>

        <li>
          <Link className="hover:text-sky-500 hover:underline" href="/notes">
            Notes
          </Link>
        </li>
      </ul>
    </nav>
  );
};

export { Navbar };


Enter fullscreen mode Exit fullscreen mode

Then update the root layout.tsx file to use it (as well as wrap children within the main element and add some padding):



// src/app/layout.tsx

const RootLayout = ({ children }: RootLayoutProps) => {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Navbar />

        <main className="p-2">{children}</main>
      </body>
    </html>
  );
};


Enter fullscreen mode Exit fullscreen mode

Create a Form to Create a Note

Link to Server Actions in Next.js docs here

Let’s add a form to the notes page.tsx that will add a Note into the database:



// src/app/notes/page.tsx

const NotesPage = () => {
  return (
    <div>
      <h1>NotesPage</h1>

      <form
        className="inline-flex items-start flex-col space-y-4 border-solid border-black border-2 p-4 mt-2 w-80"
        action={createNote}
      >
        <div className="w-full">
          <label htmlFor="name" className="block">
            Name:
          </label>

          <input
            id="name"
            name="name" // IMPORTANT: MAKE SURE TO INCLUDE A NAME FOR INPUTS
            type="text"
            className="border-solid border-black border-2 block w-full"
            required
          />
        </div>

        <div className="w-full">
          <label htmlFor="content" className="block">
            Content:
          </label>

          <textarea
            id="content"
            name="content"
            className="border-solid border-black border-2 block w-full"
            required
          />
        </div>

        <div className="w-full">
          <label htmlFor="color" className="block">
            Color:
          </label>

          <select
            id="color"
            name="color"
            className="border-solid border-black border-2 block w-full"
            required
          >
            <option value="">Select Color</option>
            <option value="yellow">Yellow</option>
            <option value="green">Green</option>
            <option value="blue">Blue</option>
            <option value="purple">Purple</option>
          </select>
        </div>

        <button
          className="border-solid border-black border-2 py-1 px-4 hover:bg-black hover:text-white w-full"
          type="submit"
        >
          Create Note
        </button>
      </form>
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode

Previously, we would have to abstract this form out into its own component and add "use client" at the top.

Now, thanks to server actions, we can keep everything in this file and write an async function that will be used as the form’s action.

To get started, we need to enable Server Actions in our Next.js project by adding the experimental serverActions flag to the next.config.js file:



// next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverActions: true
  }
};

module.exports = nextConfig;


Enter fullscreen mode Exit fullscreen mode

Server Actions can be defined in two places:

  • Inside the component that uses it (Server Components only).
  • In a separate file (Client and Server Components), for reusability. You can define multiple Server Actions in a single file.

Before we setup our server action, let’s install zod as we can use it to validate the data from the form:



npm i zod


Enter fullscreen mode Exit fullscreen mode

To keep things simple, we’ll define our action function here in the NotesPage component:



// src/app/notes/page.tsx

import { revalidatePath } from "next/cache";
import { z } from "zod";

import { db } from "@/drizzle/config";
import { notes } from "@/drizzle/schema";

const schema = z.object({
  name: z.string().min(1),
  content: z.string().min(1),
  color: z.string().min(1)
});

const NotesPage = () => {
  const createNote = async (formData: FormData) => {
    "use server";

    // validate data
    const validated = schema.parse({
      name: formData.get("name"),
      content: formData.get("content"),
      color: formData.get("color")
    });

    try {
      await db.insert(notes).values({
        name: validated.name,
        content: validated.content,
        color: validated.color
      });

      revalidatePath("/notes");

      return {
        message: "Note created successfully!",
        revalidated: true,
        now: Date.now()
      };
    } catch (error) {
      return {
        message: "Something went wrong when creating the note!"
      };
    }
  };

  return (
    <div>
      <h1>NotesPage</h1>

      <form
        className="inline-flex items-start flex-col space-y-4 border-solid border-black border-2 p-4 mt-2 w-80"
        action={createNote}
      >
        {/* ... */}
      </form>
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode

Now, open up the DevTools and go to the Network tab and clear everything out.

Next, try to submit the form with something like this:

Sample note being entered into form

When you click Create Note, open up the DevTools and check the Network tab:

POST request being sent to notes route

You’ll see it sends a POST request to the URL that we are currently on.

Not only that, but if you head over to DBeaver, you should see the note has been added to the database as well (you might need to click Refresh along the bottom of the window):

Sample note appearing in local postgres db

Fetch and Display Notes

Now let’s add another async function, this time outside the component body, to query the database and get all the notes:



// src/app/notes/page.tsx

const schema = z.object({
  // ...
});

const getNotes = async () => {
  try {
    const nts = await db.select().from(notes);

    return nts;
  } catch (error) {
    throw new Error("Something went wrong when fetching notes!");
  }
};


Enter fullscreen mode Exit fullscreen mode

Then let’s update the page component to use it, as well as set up colorVariants for dynamic background color. I personally don't like this approach. I would much rather just use string interpolation within the className prop, but this is just how it's done with Tailwind:



// src/app/notes/page.tsx

// ...

const colorVariants = {
  blue: "bg-blue-200",
  green: "bg-green-200",
  purple: "bg-purple-200",
  yellow: "bg-yellow-200"
};

const NotesPage = async () => {
  const createNote = async (formData: FormData) => {
    "use server";

    // ...
  };

  const nts = await getNotes();

  return (
    <div>
      <h1 className="text-4xl">NotesPage</h1>

      <form
        className="inline-flex items-start flex-col space-y-4 border-solid border-black border-2 p-4 mt-2 w-80"
        action={createNote}
      >
          {/*  */}
      </form>

      <div className="mt-4">
              <h2 className="text-2xl">Notes</h2>

                <ul className="grid gap-4 grid-cols-12">
                  {nts.length
                    ? nts.map((nt) => {
                        // @ts-ignore
                        const colorClasses = colorVariants[nt.color];

                        return (
                          <li key={nt.id} className={`${colorClasses} col-span-2 p-4 rounded-lg`}>
                            <h3 className="text-xl font-semibold mb-1">{nt.name}</h3>
                            <p>{nt.content}</p>
                          </li>
                        );
                      })
                    : null}
                </ul>
            </div>
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode

Now our notes will appear in a nice little grid!

Sample notes appearing in a grid

Deploy - Prerequisites

Now time for the main attraction: deployment!

If you’ve never deployed a full-stack app like this before, it’s a great learning experience. It certainly was for me!

First, there are a few prerequisites:

  1. Docker: Make sure you have Docker installed on your local machine.
  2. Docker Compose: Install Docker Compose, which is a tool for defining and running multi-container Docker applications.
  3. DigitalOcean Account: Set up a DigitalOcean account and create a VPS (Virtual Private Server) instance.

I’ll walk you through each of these steps now.

Docker and Docker Compose

You can check if docker is already installed on your machine by going to the root directory of your machine, and running this command:



docker --version
# Docker version 24.0.6, build ed223bc


Enter fullscreen mode Exit fullscreen mode

If not, then you will need to check the installation instructions online to install Docker for your machine.

Similarly, you can run this command to check if docker compose is installed on your machine or not:



docker compose version
# Docker Compose version v2.20.3


Enter fullscreen mode Exit fullscreen mode

You can find installation instructions for Docker Compose here.

DigitalOcean

Next, head over to DigitalOcean’s website and click Sign Up if don’t have an account, or Login if you already have one. I choose to Sign up with GitHub

If you chose to Sign up as well, here’s what I selected in the welcome screen:

  • What do you plan to build on DigitalOcean? A web or mobile application
  • What is your role or business type? Hobbyist or Student
  • What is your monthly spend on cloud infrastructure across cloud platforms? (Provide an estimate): $0 - $50
  • How many employees work at your company? I work alone

Click Submit

Add a payment method of your choice.

Once logged in, you should land on a page that looks like this:

https://cloud.digitalocean.com/welcome

I would suggest removing the /welcome part, and go straight to https://cloud.digitalocean.com/

This should redirect you to a /projects page, with a default project already created called first-project

First, we need to Create a Droplet

To do so, click the green Create button in the top right corner, and click Droplets from the dropdown menu

Choose a Region that is closest to your location

Leave Datacenter selection alone

For Choose an image, I stuck with the default Ubuntu on Version 23.04 x64

For Droplet Type, Basic (Plan selected) is the cheapest choice

For CPU options, I selected:

  • Premium Intel (Disk: NVMe SSD)
  • $8/mo - 1GB / 1 Intel CPU, 35GB NVMe SSDs, 1000GB transfer

For Choose Authentication Method, we will be using an SSH Key.

Thankfully DigitalOcean has a nice prompt saying “We can walk you through setting up your first SSH key”, which I will walk you through now. Click on Add SSH Key.

Creating an SSH Key

Open a terminal and run the following command (at the root level):



sudo ssh-keygen


Enter fullscreen mode Exit fullscreen mode

You will be prompted to save and name the key.

I will save mine in the /usr/local/bin directory. Again, I am on Ubuntu, so you will need to pick a location that works for your machine.



/usr/local/bin/digital_ocean_ssh


Enter fullscreen mode Exit fullscreen mode

Next you will be asked to create and confirm a passphrase for the key. This is highly recommended, and you can use a site like this one to generate a strong passphrase.



Enter passphrase (empty for no passphrase):
Enter same passphrase again:


Enter fullscreen mode Exit fullscreen mode

Store what you’ve entered into a text file, or the .env.local file as a comment, for now

You should see the following output:



Your identification has been saved in digital_ocean_ssh
Your public key has been saved in digital_ocean_ssh.pub
The key fingerprint is:
.....
The key's randomart image is:
.....


Enter fullscreen mode Exit fullscreen mode

Copy and paste this output into the same location that you pasted the filename and password from the previous step.

We need to copy the contents of the .pub file into the SSH ley field in the modal

We can do that by running:



cat PATH_TO_YOUR_PUB_FILE
# ex: cat /usr/local/bin/digital_ocean_ssh.pub


Enter fullscreen mode Exit fullscreen mode

Either way, once you run the command, you should see an output that starts like this:



ssh-rsa REALLY_LONG_ASS_STRING_HERE


Enter fullscreen mode Exit fullscreen mode

Copy this, and paste it into the SSH key content input field back on DigitalOcean

Give it a name. I named mine DigitalOcean Next.js SSH

Click Add SSH Key

Finish Droplet Setup

Under options, you can click to Add improved metrics monitoring and alerting (free), since it is, well, free. The others are up to you.

Scroll down to Finalize Details

Add some tags if you want, I added a few:

  • next_js
  • digital_ocean
  • postgres

Click Create Droplet

Wait for the blue progress bar to finish, and eventually you should see a notification that the Droplet was added to the project.

Test Droplet Connection

Once your Droplet is created, you can access it via SSH.

Click on it, and copy the ipv4 address to the same text file / .env.local file as a comment.

Run this command at root level (double check the path):



ssh -i /PATH_TO_YOUR_SSH_FILE root@YOUR_DROPLET_IP
# ex: ssh -i /usr/local/bin/digital_ocean_ssh root@##.###.###.##


Enter fullscreen mode Exit fullscreen mode

If you should see this:



Are you sure you want to continue connecting (yes/no/[fingerprint])?
Please type 'yes', 'no' or the fingerprint:


Enter fullscreen mode Exit fullscreen mode

Enter in fingerprint, then yes

You will then see this:



Enter passphrase for key '/home/andrew/digital_ocean_ssh':


Enter fullscreen mode Exit fullscreen mode

Copy and paste the passphrase you created earlier and hit Enter

You should now see your terminal change to the root of your droplet:



root@ubuntu-s-1vcpu-1gb-35gb-intel-sfo3-01:~#


Enter fullscreen mode Exit fullscreen mode

You can exit anytime by running the exit command.

Create a PostgreSQL Database Cluster

Back at the main dashboard, click the green Create button in the top right corner, and click Databases from the dropdown menu.

Choose the datacenter region closest to you.

For the database engine, select PostgreSQL (v15).

Choose whichever database configuration that best suits your budget. The same goes for storage size. I went with 20GB.

Make sure the project selected is first-project.

When you’re ready, click the Create a Database Cluster.

It’ll take approximately 5 minutes for the database to finish provisioning.

Setup Dockerfile and docker-compose.yml

While the database is provisioning, let’s Dockerize our Next.js app

Head back to the code and add these 2 files at the root level of the project:



touch Dockerfile docker-compose.yml


Enter fullscreen mode Exit fullscreen mode

In the Dockerfile, add the following code:



# Dockerfile

# Use an official Node.js runtime as the base image
FROM node:18-alpine

# Set the working directory in the container
WORKDIR /usr/src/app

# Copy package.json and package-lock.json to the container
COPY package.json package-lock.json ./

# Install dependencies
RUN npm install

# Copy the rest of the application files to the container
COPY . .

# Build the Next.js application for production
RUN npm run build

# Expose the application port (assuming your app runs on port 3000)
EXPOSE 3000

# Start the application
CMD ["npm", "start"]


Enter fullscreen mode Exit fullscreen mode

And in the docker-compose.yml file, add the following:



# docker-compose.yml

version: "3"

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production

  db:
    image: postgres:latest
    environment:
      POSTGRES_USER: "${POSTGRES_USERNAME}"
      POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
      POSTGRES_DB: "${POSTGRES_DATABASE}"


Enter fullscreen mode Exit fullscreen mode

As you can see, we are using environment variables for some of our values related to Postgres.

We want to keep this information secure, and not have it in plain sight.

While we already have a .env.local, the docker-compose.yml reads from .env only.

So, duplicate the .env.local file, and rename it to .env.

You will also want to make sure to update the .gitignore:



# local env files
.env
.env*.local


Enter fullscreen mode Exit fullscreen mode

In the .env file, have the following:



# .env

DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"

POSTGRES_USERNAME="postgres"
POSTGRES_PASSWORD="postgres"
POSTGRES_DATABASE="postgres"


Enter fullscreen mode Exit fullscreen mode

Next, make sure to push these changes up to GitHub!

Finish DigitalOcean Postgres Setup

Head back over to DigitalOcean, and under the Getting Started section, skip ahead a bit and click on 4 Next Steps.

This will contain the migrate command that we will need shortly. Click the blue show-password link, and then copy / paste the command somewhere. You can click Hide.

Import Local DB To DigitalOcean

Since we already have a local PostgreSQL database, lets export it and then import that into DigitalOcean.

To export an existing database, we can use the pg_dump command (run this at the root level of your machine):



cd ~
pg_dump -h localhost -p 5432 -U postgres -d postgres -F c -b -v -f ~/Documents/db_backup.pgsql


Enter fullscreen mode Exit fullscreen mode

When you run this command, it will prompt you for the password, which is also just postgres. I am on Ubuntu, so I chose the Documents folder for convenience sake. Feel free to update the path as needed.

Here’s a quick explanation of the command options:

  • h localhost: Specifies the host where your PostgreSQL server is running.
  • p 5432: Specifies the port number on which PostgreSQL is listening.
  • U postgres: Specifies the username to connect to the database.
  • d postgres: Specifies the name of the database you want to export.
  • F c: Specifies the custom format for the dump file.
  • b: Includes large objects in the dump.
  • v: Enables verbose mode to see the progress.
  • f ~/Documents/db_backup.dump: Specifies the output file path and name. In this case, the dump file will be saved in the Documents folder with the name db_backup.dump.

And there it is!

db_backup.pgsql dump file in documents folder

Now we need to import this into DigitalOcean.

But before we do, we can add a connection to our DigitalOcean PostgreSQL database in DBeaver. I think this is just nice to have, so we can make sure things are working properly.

In DBeaver, click Database and then New Database Connection

Select PostgreSQL

Have Connect by set to Host

The URL field will not be adjustable, but that’s ok as it will auto update with the information we provide.

Simply copy and paste the information from DigitalOcean into their respective field. When you copy + paste the host, you should see the URL update.

You will need to update the:

  • Host
  • Database
  • Port
  • Username
  • Password

Once you do, click Test Connection. If you see something like this:

DigitalOcean PostgreSQL db connection success

You’re good to proceed! I'm simply cutting off most of the information within the popup.

Click Ok to close this little popup, and then click Finish

Once the connection has been added to your sidebar, you can rename it if you like to make it clearer what the connection is for:

Renaming DigitalOcean Postgres db to note_taker

You can also see here there are no tables, but like before with the local setup, that’s ok!

You can view the DigitalOcean docs on Importing a database here if you like.

To import the database, we’ll take the migrate command copied from earlier, and just add on the path to our db_backup.pgsql file:



PGPASSWORD=[YOUR_DB_PASSWORD] pg_restore -U [YOUR_DB_USERNAME] -h [YOUR_DB_HOST] -p [YOUR_DB_PORT] -d [YOUR_DB_NAME] ~/Documents/db_backup.pgsql


Enter fullscreen mode Exit fullscreen mode

Once you run this, you should see an output like this:



pg_restore: error: could not execute query: ERROR:  must be member of role "postgres"
Command was: ALTER TABLE public.notes OWNER TO postgres;

pg_restore: warning: errors ignored on restore: 1


Enter fullscreen mode Exit fullscreen mode

Which seems like something went wrong, but don’t worry! If you head over to DBeaver, you should see that the notes table has been added!

notes table added to digitalocean postgres db

Deploy to Droplet Using Docker Compose

To get started, in a terminal window, SSH into your DigitalOcean VPS like before. Within our VPS, we’ll need to install the following:

  • Docker
  • Docker Compose
  • PostgreSQL

Thankfully, it’s very easy to do so.

Install Docker:



# Update the system packages
sudo apt update

# Install necessary packages to allow apt to use a repository over HTTPS
sudo apt install apt-transport-https ca-certificates curl software-properties-common

# Add Docker's official GPG key
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg

# Add Docker repository
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# Update the system packages again
sudo apt update

# Install Docker
sudo apt install docker-ce docker-ce-cli containerd.io


Enter fullscreen mode Exit fullscreen mode

Verify Docker installation:



docker --version
# Docker version 24.0.6, build ed223bc


Enter fullscreen mode Exit fullscreen mode

Install Docker Compose:



# Download the latest version of Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose

# Make Docker Compose executable
sudo chmod +x /usr/local/bin/docker-compose


Enter fullscreen mode Exit fullscreen mode

Verify Docker Compose installation:



docker compose version
# Docker Compose version v2.21.0


Enter fullscreen mode Exit fullscreen mode

Install PostgreSQL:



sudo apt update
sudo apt install postgresql postgresql-contrib


Enter fullscreen mode Exit fullscreen mode

Verify PostgreSQL installation:



psql --version
# psql (PostgreSQL) 15.4 (Ubuntu 15.4-0ubuntu0.23.04.1)


Enter fullscreen mode Exit fullscreen mode

cd back to the root level, and if you run the command ls, all you should see listed is snap

Create a new directory called repos and cd into it



mkdir repos
cd repos/


Enter fullscreen mode Exit fullscreen mode

In this repos folder, clone the GitHub repo you setup at the start by running:



git clone https://github.com/[YOUR_GITHUB_USERNAME]/[YOUR_REPO_LINK].git


Enter fullscreen mode Exit fullscreen mode

Once the clone is complete, cd into the directory, and run ls to verify the contents:



cd [YOUR_REPO_NAME]
ls

# output:

# ls
Dockerfile  docker-compose.yml  next.config.js     package.json       public  tailwind.config.ts
README.md   drizzle.config.ts   package-lock.json  postcss.config.js  src     tsconfig.json


Enter fullscreen mode Exit fullscreen mode

Great!

One issue here though is we don’t have an .env file, which makes sense since we added it to the .gitignore. Thankfully, it’s very easy to not only create this file, but write to it as well.

You can create it by running:



touch .env


Enter fullscreen mode Exit fullscreen mode

And if you run ls again, it’s not there? What gives??

The .env file is considered a hidden file. To view hidden files only within the current directory, you can run this command:



ls -ld .?*


Enter fullscreen mode Exit fullscreen mode

You should now see .env listed, along with .git and .gitignore.

What do we need to write the .env file?

We will need DATABASE_URL for our Drizzle config + schema, and as well as POSTGRES_USERNAME, POSTGRES_PASSWORD, and POSTGRES_DATABASE for our docker-compose.yml.

To write to the .env file, you can run the following command:



echo "DATABASE_URL=[YOUR_CONNECTION_STRING_URI]" > .env
echo "POSTGRES_DATABASE=[YOUR_DATABASE_NAME]" >> .env
echo "POSTGRES_USERNAME=[YOUR_DATABASE_USERNAME]" >> .env
echo "POSTGRES_PASSWORD=[YOUR_DATABASE_PASSWORD]" >> .env


Enter fullscreen mode Exit fullscreen mode

You can get all the necessary info from your DigitalOcean dashboard. I’d recommend copying and pasting the above command into a text file so you can easily swap out the placeholders for your values, and then copy and paste all 4 lines at once with the correct values into your terminal to run them one after another.

You can then verify the write was successful by running the cat command:



cat .env

# output:
# DATABASE_URL=...
# POSTGRES_DATABASE=...
# POSTGRES_USERNAME=...
# POSTGRES_PASSWORD=...


Enter fullscreen mode Exit fullscreen mode

Finally, we can start our services using Docker Compose in detached mode:



docker compose up -d


Enter fullscreen mode Exit fullscreen mode

It may take a bit of time to start up the services. but after a while you should see something like this:



[+] Running 3/3
 ✔ Network [YOUR_REPO_NAME]_default  Created
 ✔ Container [YOUR_REPO_NAME]-db-1   Started
 ✔ Container [YOUR_REPO_NAME]-web-1  Started


Enter fullscreen mode Exit fullscreen mode

Now, if you open a new browser tab and go to the following address:



http://[YOUR_DROPLET_IPV4_ADDRESS]:3000


Enter fullscreen mode Exit fullscreen mode

Voila! Your app is now running in a VPS! 🎉

Outro

I really hope you enjoyed this post and found it helpful! Some next steps to consider would be to deploy the containers as a Kubernetes cluster on DigitalOcean. However, I am a Docker noobie, and setting up a cluster with HTTPS seems like it’s a bit out of reach for me at this time. I am very happy I went through this process, and I hope you enjoyed it as well. And going through this process has definitely motivated me to learn more about Docker and Kubernetes.

As always, here is a link to my repo with the full source code you can use as a reference in case you get stuck.

Cheers, and happy coding!

Top comments (1)

Collapse
 
manuelentrena profile image
Manuel Entrena

Great post!