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
You can check the installation was successful by running:
psql --version
# psql (PostgreSQL) 16.0 (Ubuntu 16.0-1.pgdg22.04+1)
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
You’ll enter the postgres console. Once there, enter:
\password postgres
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
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:
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 .
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;
}
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"]
}
Update src/app/page.tsx
to this:
// src/app/page.tsx
const HomePage = () => {
return (
<div>
<h1>HomePage</h1>
</div>
);
};
export default HomePage;
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;
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
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;
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"
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 };
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()
});
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"
},
}
Then in the terminal, run:
npm run generate
We can also push our schema changes directly to the database.
Add a push
script to package.json
:
{
// ...
"scripts": {
// ...
"push": "drizzle-kit push:pg"
},
}
Then run the command:
npm run push
If the push was successful, you should now see a notes table in DBeaver!
And if you double click on it, and switch to the Data tab, you can see the table!
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"
},
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 ../../../
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;
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 };
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>
);
};
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>
);
};
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;
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
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>
);
};
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:
When you click Create Note, open up the DevTools and check the Network tab:
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):
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!");
}
};
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>
);
};
Now our notes will appear in a nice little 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:
- Docker: Make sure you have Docker installed on your local machine.
- Docker Compose: Install Docker Compose, which is a tool for defining and running multi-container Docker applications.
- 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
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
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
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
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:
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:
.....
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
Either way, once you run the command, you should see an output that starts like this:
ssh-rsa REALLY_LONG_ASS_STRING_HERE
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@##.###.###.##
If you should see this:
Are you sure you want to continue connecting (yes/no/[fingerprint])?
Please type 'yes', 'no' or the fingerprint:
Enter in fingerprint
, then yes
You will then see this:
Enter passphrase for key '/home/andrew/digital_ocean_ssh':
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:~#
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
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"]
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}"
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
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"
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
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 namedb_backup.dump
.
And there it is!
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:
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:
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
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
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!
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
Verify Docker installation:
docker --version
# Docker version 24.0.6, build ed223bc
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
Verify Docker Compose installation:
docker compose version
# Docker Compose version v2.21.0
Install PostgreSQL:
sudo apt update
sudo apt install postgresql postgresql-contrib
Verify PostgreSQL installation:
psql --version
# psql (PostgreSQL) 15.4 (Ubuntu 15.4-0ubuntu0.23.04.1)
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/
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
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
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
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 .?*
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
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=...
Finally, we can start our services using Docker Compose in detached mode:
docker compose up -d
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
Now, if you open a new browser tab and go to the following address:
http://[YOUR_DROPLET_IPV4_ADDRESS]:3000
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)
Great post!