How to Use Vultr Object Storage in AdonisJS

Updated on February 2, 2023
How to Use Vultr Object Storage in AdonisJS header image

Introduction

Drive is a storage abstraction library in AdonisJS. It provides a consistent API that works across all storage providers.

Drive has an S3 driver to support S3-compatible cloud storage like Vultr Object Storage. This guide explains how to configure the AdonisJS Drive for Vultr Object Storage and use it to store and read files.

Prerequisites

Before you begin, you should:

Install Node.js

AdonisJS requires at least Node.js version 14. You can install the latest version of Node.js using Node Version Manager (NVM).

  1. Install NVM:

     $ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash
  2. Disconnect and reconnect your ssh session.

  3. Install Node.js:

     $ nvm install node
  4. Check the Node.js version:

     $ node -v
     v19.3.0

Create Object Storage

  1. Log in to Vultr customer portal.
  2. Navigate to Products -> Objects.
  3. Add Object Storage. Choose the region and give it a label.
  4. Click your Object Storage and navigate to the Bucket tab.
  5. Create a Bucket and give it a name.
  6. Take note of the Hostname, the Secret Key, the Access Key, and the Bucket Name.

Create New AdonisJS App

Go to the home directory.

$ cd ~

Create a new directory for your app.

$ mkdir app

Go to the app directory and generate a new AdonisJS app using the npm init command.

$ cd app
$ npm init adonis-ts-app@latest website
  • Select the web project structure.
  • Choose `y' when it prompts you to configure the Webpack Encore.

It generates your app in the website directory. For the remaining tasks, you need to run them in the website directory.

$ cd website

Install Redis

The sample app in this guide uses Redis to store the image filenames.

Install Redis:

$ sudo apt install redis-server

Set up Redis persistent mode:

  1. Open Redis configuration file:

     $ sudo nano /etc/redis/redis.conf
  2. Change the appendonly no to appendonly yes.

  3. Save file and exit.

  4. Restart Redis.

     $ sudo systemctl restart redis-server

Install and configure the AdonisJS Redis package:

$ npm i @adonisjs/redis
$ node ace configure @adonisjs/redis

Open the env.ts file:

$ nano env.ts

Add the following rules:

REDIS_CONNECTION: Env.schema.enum(['local'] as const),
REDIS_HOST: Env.schema.string({ format: 'host' }),
REDIS_PORT: Env.schema.number(),
REDIS_PASSWORD: Env.schema.string.optional(),

Configure AdonisJS Drive

AdonisJS Drive has an S3 driver to interact with S3-compatible cloud storage like Vultr Object Storage.

Install and configure the S3 driver:

$ npm i @adonisjs/drive-s3
$ node ace configure @adonisjs/drive-s3

Open the env.ts file:

$ nano env.ts

Update the DRIVE_DISK rules:

DRIVE_DISK: Env.schema.enum(['local','s3'] as const),

Add the following rules:

S3_KEY: Env.schema.string(),
S3_SECRET: Env.schema.string(),
S3_BUCKET: Env.schema.string(),
S3_REGION: Env.schema.string(),
S3_ENDPOINT: Env.schema.string.optional(),

Open the config/drive.ts file:

$ nano config/drive.ts

Add the S3 configurations inside the disks object:

s3: {
    driver: 's3',
    visibility: 'public',
    key: Env.get('S3_KEY'),
    secret: Env.get('S3_SECRET'),
    region: Env.get('S3_REGION'),
    bucket: Env.get('S3_BUCKET'),
    endpoint: Env.get('S3_ENDPOINT'),
}

Open the .env file:

$ nano .env

Update the DRIVE_DISK value to s3:

DRIVE_DISK=s3

Add the Vultr Object Storage credentials:

S3_KEY=
S3_SECRET=
S3_BUCKET=adonis-drive
S3_REGION=sgp1
S3_ENDPOINT=https://sgp1.vultrobjects.com
  • S3_KEY is your Vultr Object Storage Access Key.
  • S3_SECRET is your Vultr Object Storage Secret Key.
  • S3_BUCKET` is your Vultr Object Storage Bucket name.
  • S3_ENDPOINT is your Vultr Object Storage Hostname.
  • S3_REGION is your Vultr Object Storage Region.

Add Tailwind CSS

This guide uses Tailwind CSS for the CSS framework. Install Tailwind CSS and its dependencies via NPM:

$ npm install -D tailwindcss postcss autoprefixer postcss-loader

Open the webpack.config.js file:

$ nano webpack.config.js

Enable the PostCSS loader:

Encore.enablePostCssLoader()

Create and open the Tailwind CSS configuration file:

$ npx tailwindcss init -p
$ nano tailwind.config.js

Change the content to:

/** @type {import('tailwindcss').Config} */
module.exports = {
    content: [
        "./resources/**/*.edge",
        "./resources/**/*.js",
    ],
    theme: {
        extend: {},
    },
    plugins: [],
}

Open the resources/css/app.css file and replace the content to Tailwind CSS directives:

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

Add JavaScript

Open the resources/js/app.js file and add the following scripts:

import '../css/app.css'

document.getElementById('fileImage').addEventListener('change',function(){
  if( this.files.length > 0 ){
      document.getElementById('uploadBtn').removeAttribute('disabled');
  }
});

The scripts enable the upload button after the user selects the image file.

Create View

Create a resources/views/gallery.edge file:

$ nano resources/views/gallery.edge

Add the following code:

<html>
    <head>
        <title>Gallery</title>

        @entryPointStyles('app')
    </head>
    <body>
        <div class="max-w-7xl m-auto">
            <h1 class="text-3xl font-bold text-gray-900 text-center py-8 uppercase">Gallery</h1>
            <form action="" method="post" enctype="multipart/form-data" class="flex flex-wrap text-center justify-center items-start p-4 rounded-lg">
                <label class="block py-1">
                  <input id="fileImage" type="file" name="fileImage" class="block w-full text-sm text-slate-500 pr-6
                    file:cursor-pointer
                    file:mr-4 file:py-2 file:px-4
                    file:rounded-full file:border-0
                    file:text-sm file:font-semibold
                    file:bg-indigo-50 file:text-indigo-700
                    hover:file:bg-indigo-100
                  "/>

                  @if (flashMessages.has('errors.fileImage'))
                    <span class="block text-red-700 py-4 text-left">{{ flashMessages.get('errors.fileImage') }}</span>
                  @endif
                </label>
                <button id="uploadBtn" disabled class="rounded border border-transparent bg-indigo-600 px-6 py-2 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50" type="submit">
                    Upload Image
                </button>
            </form>

            <div class="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 sm:gap-x-6 lg:grid-cols-4 xl:gap-x-8 ">
                @each(image in images)
                    <div>
                        <img class="rounded" src="{{ image }}">
                    </div>
                @end
            </div>
        </div>

        @entryPointScripts('app')
    </body>
</html>

Create Controller

Create and open the GalleryController.ts file:

$ node ace make:controller GalleryController -e
$ nano app/Controllers/Http/GalleryController.ts

Import libraries and helpers at the top of the file:

import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import Drive from '@ioc:Adonis/Core/Drive'
import Redis from '@ioc:Adonis/Addons/Redis'
import { string } from '@ioc:Adonis/Core/Helpers'
import { schema } from '@ioc:Adonis/Core/Validator'

Create the index action. It shows the upload form and lists all images from your Vultr Object Storage. It gets the image filenames from Redis and calls the getUrl method from the Drive library to get the URL of each image.

public async index({ view }: HttpContextContract) {
    const galleryString = await Redis.get('gallery')
    const gallery = (galleryString) ? JSON.parse(galleryString) : []

    let images:string[] = []

    for (const filename of gallery) {
        const url = await Drive.getUrl(`gallery/${filename}`)
        images.push(url)
    }

    return view.render('gallery', { images })
}

Create the upload action. It handles when a user uploads their images. It validates the file, saves the filename to Redis, and then stores the file in Vultr Object Storage using the moveToDisk method.

public async upload({ request, response }: HttpContextContract) {
    const imageSchema = schema.create({
        fileImage: schema.file({
            extnames: ['jpg', 'png', 'gif']
        }),
    })

    const payload = await request.validate({ schema: imageSchema })
    const filename = `${string.generateRandom(32)}.${payload.fileImage.extname}`

    if (payload.fileImage) {
        await payload.fileImage.moveToDisk(", {
            name: `gallery/${filename}`
        }, 's3')

        const galleryString = await Redis.get('gallery')

        let gallery:string[] = []

        if (galleryString) {
            gallery = JSON.parse(galleryString)
        }

        gallery.push(filename)
        await Redis.set('gallery', JSON.stringify(gallery))
    }

    return response.redirect().toPath('/')
}

You need to save and get the image filenames from Redis because the AdonisJS S3 driver cannot get a list of files in a bucket or folder. It can only get one file at a time.

The following is the full content of the GalleryController.ts file:

import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import Drive from '@ioc:Adonis/Core/Drive'
import Redis from '@ioc:Adonis/Addons/Redis'
import { string } from '@ioc:Adonis/Core/Helpers'
import { schema } from '@ioc:Adonis/Core/Validator'

export default class GalleryController {
    public async index({ view }: HttpContextContract) {
        const galleryString = await Redis.get('gallery')
        const gallery = (galleryString) ? JSON.parse(galleryString) : []

        let images:string[] = []

        for (const filename of gallery) {
            const url = await Drive.getUrl(`gallery/${filename}`)
            images.push(url)
        }

        return view.render('gallery', { images })
    }

    public async upload({ request, response }: HttpContextContract) {
        const imageSchema = schema.create({
            fileImage: schema.file({
                extnames: ['jpg', 'png', 'gif']
            }),
        })

        const payload = await request.validate({ schema: imageSchema })
        const filename = `${string.generateRandom(32)}.${payload.fileImage.extname}`

        if (payload.fileImage) {
            await payload.fileImage.moveToDisk(", {
                name: `gallery/${filename}`
            }, 's3')

            const galleryString = await Redis.get('gallery')

            let gallery:string[] = []

            if (galleryString) {
                gallery = JSON.parse(galleryString)
            }

            gallery.push(filename)
            await Redis.set('gallery', JSON.stringify(gallery))
        }

        return response.redirect().toPath('/')
    }
}

Add Routes

Open the start/routes.ts file:

$ nano start/routes.ts

Add the following code:

Route.get('/', 'GalleryController.index')
Route.post('/', 'GalleryController.upload')

Test the Application

  1. To test your app, you need to disable the Ubuntu firewall.

     $ sudo ufw disable

    You can enable it again when you build your app for production.

  2. Start a development server:

     $ node ace serve --encore-args="--host [VULTR_VPS_IP_ADDRESS]"
  3. Open the http://[VULTR_VPS_IP_ADDRESS]:3333 in a browser.

  4. You should see the upload form.

  5. Upload an image.

  6. Check if the image appears in your Vultr Object Storage and the gallery below the upload form.

  7. Press the Ctrl+C to stop the development server.

Use Multiple Object Storage Locations

Vultr Object Storage is available in multiple locations. The followings are the locations that Vultr supports:

  • Amsterdam: ams1.vultrobjects.com
  • New Jersey: ewr1.vultrobjects.com
  • Silicon Valley: sjc1.vultrobjects.com
  • Singapore: sgp1.vultrobjects.com

You can use multiple object storage locations in the AdonisJS app to add redundancy to your files. To do that, you add a disk configuration for each location in the config/drive.ts file. Disk in AdonisJS Drive represents a particular storage driver and location.

Create New Object Storage

  1. Go to Vultr customer portal.
  2. Navigate to Products -> Objects.
  3. Add Object Storage. Choose a different region, for example, Amsterdam.
  4. Create a bucket in the new object storage.
  5. Take note of the Hostname, the Secret Key, the Access Key, and the Bucket Name.

Configure New Disk

Open the config/drive.ts file:

$ nano config/drive.ts

Add a new disk configuration in the S3 section. You can set the disk name to anything, for example, s3ams. Add a suffix to the environment variable names to differentiate them from the first disk.

s3ams: {
  driver: 's3',
  visibility: 'public',
  key: Env.get('S3_KEY_AMS'),
  secret: Env.get('S3_SECRET_AMS'),
  region: Env.get('S3_REGION_AMS'),
  bucket: Env.get('S3_BUCKET_AMS'),
  endpoint: Env.get('S3_ENDPOINT_AMS'),
},

Open the .env file:

$ nano .env

Add the credentials of your object storage:

S3_KEY_AMS=
S3_SECRET_AMS=
S3_BUCKET_AMS=adonis-drive
S3_REGION_AMS=ams1
S3_ENDPOINT_AMS=https://ams1.vultrobjects.com
  • S3_KEY_AMS is your Vultr Object Storage Access Key.
  • S3_SECRET_AMS is your Vultr Object Storage Secret Key.
  • S3_BUCKET_AMS is your Vultr Object Storage Bucket name.
  • S3_ENDPOINT_AMS is your Vultr Object Storage Hostname.
  • S3_REGION_AMS is your Vultr Object Storage Region.

Open the env.ts file:

$ nano env.ts

Add the s3ams disk name to the DRIVE_DISK enum value:

DRIVE_DISK: Env.schema.enum(['local','s3','s3ams'] as const),

Add validation rules for s3ams disk environment variables:

S3_KEY_AMS: Env.schema.string(),
S3_SECRET_AMS: Env.schema.string(),
S3_BUCKET_AMS: Env.schema.string(),
S3_REGION_AMS: Env.schema.string(),
S3_ENDPOINT_AMS: Env.schema.string.optional(),

Save the file and exit.

Update Controller

Open the GalleryController.ts file:

$ nano app/Controllers/Http/GalleryController.ts

In the upload action, search for the code that is responsible for uploading the file to the object storage.

await payload.fileImage.moveToDisk(", {
    name: `gallery/${filename}`
}, 's3')

Duplicate the code and change the disk name to s3ams.

await payload.fileImage.moveToDisk(", {
    name: `gallery/${filename}`
}, 's3')

await payload.fileImage.moveToDisk(", {
    name: `gallery/${filename}`
}, 's3ams')

It uploads the file to both of your object storage locations.

Set the Default Disk

The index action in the GalleryController.ts uses the default disk to get the image URL. To change the default disk, open the .env file:

$ nano .env

Update the DRIVE_DISK value to the disk that you want:

DRIVE_DISK=s3ams

Save the file and exit.

Test the App

  1. Start a development server:

     $ node ace serve --encore-args="--host [VULTR_VPS_IP_ADDRESS]"
  2. Open the http://[VULTR_VPS_IP_ADDRESS]:3333 in a browser.

  3. Upload an image.

  4. Check if the image appears in both Vultr Object Storage locations and the gallery below the upload form.

  5. Press the Ctrl+C to stop the development server.

Build for Production

AdonisJS application uses TypeScript. You need to compile it to JavaScript before running it in production.

Go to the website folder:

$ cd ~/app/website

Compile it using the build command. The result is in the build folder.

$ node ace build --production --ignore-ts-errors

Copy the .env file to the build folder and open it:

$ cp .env build/.env
$ nano build/.env

Set the NODE_ENV variable in the .env file to production:

NODE_ENV=production

Install production-only dependencies to the build folder:

$ cd build
$ npm ci --production

Run App on Production Using PM2

PM2 is a daemon process manager. It helps you run and manage your AdonisJS application on production.

Install the latest PM2 package:

$ npm install pm2@latest -g

Create the PM2 ecosystem file to manage your app:

$ cd ~/app
$ nano ecosystem.config.js

Add these configurations to the ecosystem.config.js file:

module.exports = {
    apps : [
        {
            name   : "website",
            script : "./website/build/server.js"
        }
    ]
}
  • Put your app name in the name parameter.
  • Put the production path of script.js in the script parameter.

Run your app:

$ pm2 start ecosystem.config.js
$ pm2 list

At this point, your app is running. But, the process is not persistent yet. It means that you must run your app manually again after you restart your server.

To have a persistent app running, you need to generate a startup script for PM2.

$ pm2 startup

Copy and paste the displayed command onto the terminal:

$ sudo env PATH=$PATH:/home/ubuntu/.nvm/versions/node/v19.3.0/bin /home/ubuntu/.nvm/versions/node/v19.3.0/lib/node_modules/pm2/bin/pm2 startup systemd -u ubuntu --hp /home/ubuntu

Save your PM2 app list using this command:

$ pm2 save

Nginx Reserve Proxy

You need to set up an Nginx reserve proxy to connect your domain to your app. You put your apps behind the Nginx web server. It accepts all incoming requests and forwards them to your apps.

Add the ondrej repositories to get the latest version of Nginx.

$ sudo add-apt-repository -y ppa:ondrej/nginx-mainline
$ sudo apt update

Install Nginx:

$ sudo apt install nginx

Disable the default Nginx configuration:

$ sudo unlink /etc/nginx/sites-enabled/default

Create a new Nginx configuration file:

$ sudo nano /etc/nginx/sites-available/website

Add the following configurations. Make sure you change the domain name example.com to your domain. Save the file and exit.

server {
    listen 80;

    server_name example.com;

    location / {
        proxy_pass http://localhost:3333;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_cache_bypass $http_upgrade;
    }
}

Enable Nginx configuration:

$ sudo ln -s /etc/nginx/sites-available/website /etc/nginx/sites-enabled/

Test your configurations from syntax errors:

$ sudo nginx -t

If there are no errors then you can reload the Nginx process:

$ sudo systemctl reload nginx

Point your domains to your Vultr VPS IP address.

Configure Firewall

Set the firewall to allow ssh port:

$ sudo ufw allow 'OpenSSH'

Allow HTTP and HTTPS ports:

$ sudo ufw allow 'Nginx Full'

Enable the firewall:

$ sudo ufw enable

Check firewall status:

$ sudo ufw status

Secure Apps with Let's Encrypt SSL Certificate

Let's Encrypt provides a free SSL certificate for your website. To generate the certificate, you need to use the Certbot software tool.

Install Certbot:

$ sudo snap install core; sudo snap refresh core
$ sudo snap install --classic certbot
$ sudo ln -s /snap/bin/certbot /usr/bin/certbot

Generate the SSL certificate:

$ sudo certbot --nginx

Visit your domain in the browser and confirm it has an HTTPS connection.

Let's Encrypt certificate expires after 90 days. Certbot adds the renewal command to the systemd timer or Cron Job to renew the certificate automatically before it expires. You can verify it with the following command:

$ systemctl list-timers | grep 'certbot\|ACTIVATES'

Conclusion

This guide shows examples of using the Vultr Object Storage in AdonisJS with single or multiple object storage locations, including the steps to make the app production-ready.

Further Reading

Vultr Object Storage. AdonisJS Drive Documentation. Deploy Multiple Adonis.js Applications with PM2 and Nginx. PM2 Documentation. NGINX Reverse Proxy.