Tutorial

How to Build a WordPress Plugin (part 1)

Draft updated on Invalid Date
Default avatar

By Guillaume Kanoufi

How to Build a WordPress Plugin (part 1)

This tutorial is out of date and no longer maintained.

Introduction

In this tutorial, we will go through the process of creating a plugin for WordPress. A WordPress plugin extends the WordPress core and is intended to be reusable code or functionality across multiple projects. This is one of the most interesting things about it - you can share your code or functionality on the web.

I am sure many, if not all of you, have already searched for a plugin in the WordPress repository or any of the available market places. This is one of the reasons why WordPress is so widely used. It’s extremely extensible and has a huge community of developers around it. As of today, there are more than 39,000 publicly available free plugins on the WordPress repository.

Why should I bother making a plugin?

The plugin we are going to make in this tutorial will help automate some of the most usual functions developers do when creating a new WordPress project. By the end of this tutorial you will know how to:

  • Setup a basic WordPress plugin page
  • Create custom input fields on that plugin page
  • Validate those input fields

You might be thinking that it would be easier and faster to just copy and paste code from your last project and not to even bother with writing a custom plugin to do this. Well, this is where you are wrong!

This example will demonstrate the benefits of a WordPress plugin’s purpose by eliminating repetition. All you’ll need to do is add your plugin, change the options, and be on your way. You won’t need to worry that you forgot a function or anything because it’s all self-contained to a single plugin.

The best part about building a WordPress plugin is joining the WordPress open-source community. You can share and get feedback on your work, sell it as a premium plugin, and add it to a browsable marketplace.

Getting Started

Above is a screenshot of the final plugin we’re building. As mentioned earlier, it groups a handful of functions and settings you would usually add to each new project.

Some of cool things that we’re going to be able to do are:

  • Dynamically add new images sizes
  • Customize the login page
  • Optionally clean up the head section
  • Optionally remove injected CSS for the comment widget
  • Optionally remove injected CSS for galleries
  • Optionally add a Post slug to the body class
  • Optionally load jQuery from a CDN
  • And many other useful options

The best way to begin with a new plugin is by working on the incredibly useful WordPress Plugin Boilerplate. You might ask yourself why you would use a boilerplate instead of building from scratch. This boilerplate will get you started quick with a standardized, organized and object-oriented foundation - basically everything you want if you started from scratch.

To get started, just go to the WordPress Plugin Boilerplate Generator and fill-out the form and click on the Build button.

You now have just downloaded the generated plugin boilerplate as a .zip file. Now, simply unzip it and add it to your WordPress development installation in the plugins folder.

You might want to have a dedicated local environment for testing your plugins. We recommend using either MAMP/XAMP or using a LAMP vagrant box like the awesome Scotch Box. You should also make sure to turn on the debug functionalities of WordPress by adding the following to your wp-config.php file:

    define('WP_DEBUG', true)

This will help us check for any errors while coding our plugin.

Plugin Boilerplate Folder Structure

Now that the boilerplate of our plugin is ready and installed, let’s review a bit about the plugin folder structure before we begin with coding it.

First thing you might notice, is that we have 4 main folders:

Admin

The folder admin is where all our admin facing code will live; including CSS, JS and partials folders and our PHP admin class file class-wp-cbf-admin.php.

Includes

Here you will find:

  • The main plugin PHP class class-wp-cbf.php where we will add all our actions and filters.
  • The activator file class-wp-cbf-activator.php.
  • The deactivator file class-wp-cbf-desactivator.php, the internationalization file class-wp-cbf-i18n.php
  • The loader file class-wp-cbf-loader.php which will basically call all our actions in the main class file.
  • The languages folder which is a ready to use .pot file to make your plugin in muliple languages.
  • The public folder is the same as our admin folder except for public facing functionalities.

This leaves us with 4 files:

  • LICENCE.txt: GPL-2 license.
  • README.txt: This will include your plugin name, compatibility version, and description as seen on the plugin page in the WordPress repository. This is the first file we will edit.
  • uninstall.php: This script is called when the user clicks on the Delete link in the WordPress plugin backend.
  • wp-cbf.php: This is the main plugin bootstrap file. You will likely edit this file with the version number and the short description of your plugin.

Now that all this is cleared, it’s time to get our hands dirty. Let’s add some code to our brand new plugin!

Initialize and Add a Setting Page

If you go to the plugins page in your WordPress back-end, you will see our plugin with its title, a description, and Activate, Edit and Delete links.

If you click on Activate, it will work thanks to the activator and deactivator classes in the includes folder. This is great, but once activated, nothing really will happen yet.

We need to add a settings page where we will add our plugin options. You might also notice here that we still have a very generic description - let’s fix that first.

This short description is written in the comments of the main plugin class: wp-cbf/wp-cbf.php

Since we are at the root of our plugin, let’s update the README.txt file. You will want this to be pretty detailed explanation, especially since this is what people will see when they reach your plugin webpage. You’ll also notice installation and FAQ sections. The more you cover here, the less you might need to explain during possible support later.

If you reload your Plugins admin page now, you will see your new description.

Next, let’s add a setting page so we will be able to edit our plugin’s options.

Open the admin/class-wp-cbf-admin.php where we have 3 functions already here:

  • __construct which is instantiated whenever this class is called
  • And 2 enqueueing functions: enqueue_styles and enqueue_scripts which are used where we will add our admin related CSS and JS

After these functions, add these following 3 functions. You don’t need to add the huge comment blocks since they’re just there to help you.

    /**
    *
    * admin/class-wp-cbf-admin.php - Don't add this
    *
    **/

    /**
     * Register the administration menu for this plugin into the WordPress Dashboard menu.
     *
     * @since    1.0.0
     */

    public function add_plugin_admin_menu() {

        /*
         * Add a settings page for this plugin to the Settings menu.
         *
         * NOTE:  Alternative menu locations are available via WordPress administration menu functions.
         *
         *        Administration Menus: http://codex.wordpress.org/Administration_Menus
         *
         */
        add_options_page( 'WP Cleanup and Base Options Functions Setup', 'WP Cleanup', 'manage_options', $this->plugin_name, array($this, 'display_plugin_setup_page')
        );
    }

     /**
     * Add settings action link to the plugins page.
     *
     * @since    1.0.0
     */

    public function add_action_links( $links ) {
        /*
        *  Documentation : https://codex.wordpress.org/Plugin_API/Filter_Reference/plugin_action_links_(plugin_file_name)
        */
       $settings_link = array(
        '<a href="' . admin_url( 'options-general.php?page=' . $this->plugin_name ) . '">' . __('Settings', $this->plugin_name) . '</a>',
       );
       return array_merge(  $settings_link, $links );

    }

    /**
     * Render the settings page for this plugin.
     *
     * @since    1.0.0
     */

    public function display_plugin_setup_page() {
        include_once( 'partials/wp-cbf-admin-display.php' );
    }

Let’s review and explain those 3 functions:

add_plugin_admin_menu()

add_plugin_admin_menu(), as its name says, will add a menu item in the Settings sub-menu items. This is called by the add_options_page(). This function takes five arguments:

  • The page title: Here ‘WP Cleanup and Base Options Functions Setup’.
  • The menu title: Here ‘WP Cleanup’ as you might want to keep it small to span on just one line.
  • Capabilities: Who will be able to access this menu item (Admin, Editor, etc…).
  • The menu slug: Here as for mostly everything we will reference in this plugin we will use the plugin short name (we will access it with $this->plugin_name).
  • The callback function: If you look closely here, we are calling our 3rd function display_plugin_setup_page(). This is where our options will be displayed.

This function adds a “Settings” link to the “Deactivate | Edit” list when our plugin is activated. It takes one argument, the $links array to which we will merge our new link array.

display_plugin_setup_page()

This one is called inside our first add_plugin_admin_menu() function. It just includes the partial file where we will add our Options. It will be mostly HTML and some little PHP logic.

All this is great, but if you just save that file and go back to your plugin page, nothing new will appear yet. We first need to register these functions into your define_admin_hook.

Go to the includes folder and open includes/class-wp-cbf.php. We need to add the following define_admin_hooks() private function to get this started:

    /**
    *
    * include/class-wp-cbf.php - Don't add this
    *
    **/

    // Add menu item
    $this->loader->add_action( 'admin_menu', $plugin_admin, 'add_plugin_admin_menu' );

    // Add Settings link to the plugin
    $plugin_basename = plugin_basename( plugin_dir_path( __DIR__ ) . $this->plugin_name . '.php' );
    $this->loader->add_filter( 'plugin_action_links_' . $plugin_basename, $plugin_admin, 'add_action_links' );

Each one of these lines are calling the loader file, actions, or filter hooks. From the includes/wp-cbf-loader.php file, we can get the way we have to add our arguments for example for the first action:

  • $hook ('admin_menu'), this is the action/filter hook we will add our modifications to
  • $component ($plugin_admin), this is a reference to the instance of the object on which the action is defined, more simply, if we had a hook to the admin_hooks it will be $plugin_admin on the public hooks it will be $plugin_public
  • $callback (add_plugin_admin_menu), the name of our function
  • $priority (not set here - default is 10), priority at which the function is fired with the default being 10
  • $accepted_args (not set here - default is 1), number of arguments passed to our callback function

You can also see that we are setting up a $plugin_basename variable. It will give us the plugin main class file and is needed to add the action_links.

Now, if you refresh your plugins admin page and activate the plugin you will now see the “Settings” link and also the menu link in there.

Adding Custom Input Fields

Now we have a page to display our settings and that’s pretty good, but it’s empty. You can verify that by jumping on this page by either clicking on the “Settings” link on the “WP Cleanup” menu item.

Before you go and add all your options fields, you might want to write all your plugin options on paper with the type of field you will add. For this particular plugin, most of these will be checkboxes to enable/disable functionalities, a couple of text inputs, selects that we will cover below, and some other very specific fields (color-pickers and image uploads that we will talk about in part 2.

I would also recommend using another utility plugin to grab all the admin-specific markup that we will use. It’s not available on the WordPress repository, so you will need to get it from GitHub: WordPress Admin Style

Now, with our list of fields and some admin related markup, we can go on and add our first inputs. For our plugin’s purpose, we will be adding 4 checkboxes to start.

Open admin/partials/wp-cbf-admin-display.php since it’s the file that will display our settings page (as stated in our add_options_page()). Now add the following:

    <?php
    /**
    *
    * admin/partials/wp-cbf-admin-display.php - Don't add this comment
    *
    **/
    ?>

    <!-- This file should primarily consist of HTML with a little bit of PHP. -->
    <div class="wrap">

        <h2><?php echo esc_html(get_admin_page_title()); ?></h2>

        <form method="post" name="cleanup_options" action="options.php">

            <!-- remove some meta and generators from the <head> -->
            <fieldset>
                <legend class="screen-reader-text"><span>Clean WordPress head section</span></legend>
                <label for="<?php echo $this->plugin_name; ?>-cleanup">
                    <input type="checkbox" id="<?php echo $this->plugin_name; ?>-cleanup" name="<?php echo $this->plugin_name; ?> [cleanup]" value="1"/>
                    <span><?php esc_attr_e('Clean up the head section', $this->plugin_name); ?></span>
                </label>
            </fieldset>

            <!-- remove injected CSS from comments widgets -->
            <fieldset>
                <legend class="screen-reader-text"><span>Remove Injected CSS for comment widget</span></legend>
                <label for="<?php echo $this->plugin_name; ?>-comments_css_cleanup">
                    <input type="checkbox" id="<?php echo $this->plugin_name; ?>-comments_css_cleanup" name="<?php echo $this->plugin_name; ?>[comments_css_cleanup]" value="1"/>
                    <span><?php esc_attr_e('Remove Injected CSS for comment widget', $this->plugin_name); ?></span>
                </label>
            </fieldset>

            <!-- remove injected CSS from gallery -->
            <fieldset>
                <legend class="screen-reader-text"><span>Remove Injected CSS for galleries</span></legend>
                <label for="<?php echo $this->plugin_name; ?>-gallery_css_cleanup">
                    <input type="checkbox" id="<?php echo $this->plugin_name; ?>-gallery_css_cleanup" name="<?php echo $this->plugin_name; ?>[gallery_css_cleanup]" value="1" />
                    <span><?php esc_attr_e('Remove Injected CSS for galleries', $this->plugin_name); ?></span>
                </label>
            </fieldset>

            <!-- add post,page or product slug class to body class -->
            <fieldset>
                <legend class="screen-reader-text"><span><?php _e('Add Post, page or product slug to body class', $this->plugin_name); ?></span></legend>
                <label for="<?php echo $this->plugin_name; ?>-body_class_slug">
                    <input type="checkbox" id="<?php echo $this->plugin_name;?>-body_class_slug" name="<?php echo $this->plugin_name; ?>[body_class_slug]" value="1" />
                    <span><?php esc_attr_e('Add Post slug to body class', $this->plugin_name); ?></span>
                </label>
            </fieldset>

            <!-- load jQuery from CDN -->
            <fieldset>
                <legend class="screen-reader-text"><span><?php _e('Load jQuery from CDN instead of the basic wordpress script', $this->plugin_name); ?></span></legend>
                <label for="<?php echo $this->plugin_name; ?>-jquery_cdn">
                    <input type="checkbox"  id="<?php echo $this->plugin_name; ?>-jquery_cdn" name="<?php echo $this->plugin_name; ?>[jquery_cdn]" value="1" />
                    <span><?php esc_attr_e('Load jQuery from CDN', $this->plugin_name); ?></span>
                </label>
                        <fieldset>
                            <p>You can choose your own cdn provider and jQuery version(default will be Google Cdn and version 1.11.1)-Recommended CDN are <a href="https://cdnjs.com/libraries/jquery">CDNjs</a>, <a href="https://code.jquery.com/jquery/">jQuery official CDN</a>, <a href="https://developers.google.com/speed/libraries/#jquery">Google CDN</a> and <a href="http://www.asp.net/ajax/cdn#jQuery_Releases_on_the_CDN_0">Microsoft CDN</a></p>
                            <legend class="screen-reader-text"><span><?php _e('Choose your prefered cdn provider', $this->plugin_name); ?></span></legend>
                            <input type="url" class="regular-text" id="<?php echo $this->plugin_name; ?>-cdn_provider" name="<?php echo $this->plugin_name; ?>[cdn_provider]" value=""/>
                        </fieldset>
            </fieldset>

            <?php submit_button('Save all changes', 'primary','submit', TRUE); ?>

        </form>

    </div>

This code will generate a form and a couple of checkboxes.

If you try to check one of these checkboxes now and hit save, you will get redirected to the options.php page. This is because if you look at our form, the action attribute is linked to options.php. So let’s go on and save those options.

At this point, you might be thinking that before saving any of these options, that we should probably be first validating and sanitizing them. Well that’s exaclty what we’re going to do.

So let’s validate and sanitize those options:

Let’s open admin/class-wp-cbf.php in our editor and add a new validation function. So after our display_plugin_setup_page() function jump a couple of lines and add the following:

    /**
    *
    * admin/class-wp-cbf-admin.php
    *
    **/
    public function validate($input) {
        // All checkboxes inputs
        $valid = array();

        //Cleanup
        $valid['cleanup'] = (isset($input['cleanup']) && !empty($input['cleanup'])) ? 1 : 0;
        $valid['comments_css_cleanup'] = (isset($input['comments_css_cleanup']) && !empty($input['comments_css_cleanup'])) ? 1: 0;
        $valid['gallery_css_cleanup'] = (isset($input['gallery_css_cleanup']) && !empty($input['gallery_css_cleanup'])) ? 1 : 0;
        $valid['body_class_slug'] = (isset($input['body_class_slug']) && !empty($input['body_class_slug'])) ? 1 : 0;
        $valid['jquery_cdn'] = (isset($input['jquery_cdn']) && !empty($input['jquery_cdn'])) ? 1 : 0;
        $valid['cdn_provider'] = esc_url($input['cdn_provider']);

        return $valid;
     }

As you can see here, we just created a function called validate, and we are passing it an $input argument. We then add some logic for the checkboxes to see if the input is valid.

We’re doing this with isset and !empty which checks for us if the checkbox as been checked or not. It will assign the valid[] array the value we get from that verification. We also checked our url input field with the esc_url for a simple text field. We used a sanitize_text_field instead, but the process is the same.

We are now going to add the saving/update function for our options.

In the same file, right before the previous code, add:

    /**
    *
    * admin/class-wp-cbf-admin.php
    *
    **/
     public function options_update() {
        register_setting($this->plugin_name, $this->plugin_name, array($this, 'validate'));
     }

Here we use the register_setting() function which is part of the WordPress API. We are passing it three arguments:

  • Our option group: Here we will use our $plugin_name as it’s unique and safe.
  • Option name: You can register each option as a single, We will save all our options at once - so we will use the $plugin_name again.
  • A callback function: This is used to sanitize our options with the validation function we just created.

Now that we have registered our settings, we need to add a small line of php to our form in order to get it working properly. This line will add a nonce, option_page, action, and a http_referer field as hidden inputs.

So open up the form and update it so it look like the below code:

    <?php
    /**
    *
    * admin/partials/wp-cbf-admin-display.php - Don't add this comment
    *
    **/
    ?>

    <div class="wrap">

        <h2><?php echo esc_html( get_admin_page_title() ); ?></h2>

        <form method="post" name="cleanup_options" action="options.php">

        <?php settings_fields($this->plugin_name); ?>

        <!-- This file should primarily consist of HTML with a little bit of PHP. -->

        ...

Great - we are almost there! We’re just missing one last step. We need to register the options_update() to the admin_init hook.

Open includes/class-wp-cbf.php and register our new action:

    /**
    *
    * include/class-wp-cbf.php
    *
    **/

    // Save/Update our plugin options
    $this->loader->add_action('admin_init', $plugin_admin, 'options_update');

Let’s try our option page now. On save, the page should refresh, and you should see a notice saying “Settings saved”.

Victory is ours!

But wait… If you had a checkbox checked, it’s no longer showing as checked now…

It because we now just need to grab our “options” values and add a small condition to our inputs to reflect this.

Open again the admin/partials/wp-cbf-admin.php file and update it as follow

        <h2 class="nav-tab-wrapper">Clean up</h2>

        <form method="post" name="cleanup_options" action="options.php">

        <?php
            //Grab all options
            $options = get_option($this->plugin_name);

            // Cleanup
            $cleanup = $options['cleanup'];
            $comments_css_cleanup = $options['comments_css_cleanup'];
            $gallery_css_cleanup = $options['gallery_css_cleanup'];
            $body_class_slug = $options['body_class_slug'];
            $jquery_cdn = $options['jquery_cdn'];
            $cdn_provider = $options['cdn_provider'];
        ?>

        <?php
            settings_fields($this->plugin_name);
            do_settings_sections($this->plugin_name);
        ?>

        <!-- remove some meta and generators from the <head> -->
        <fieldset>
            <legend class="screen-reader-text">
                <span>Clean WordPress head section</span>
            </legend>
            <label for="<?php echo $this->plugin_name; ?>-cleanup">
                <input type="checkbox" id="<?php echo $this->plugin_name; ?>-cleanup" name="<?php echo $this->plugin_name; ?>[cleanup]" value="1" <?php checked($cleanup, 1); ?> />
                <span><?php esc_attr_e('Clean up the head section', $this->plugin_name); ?></span>
            </label>
        </fieldset>

        <!-- remove injected CSS from comments widgets -->
        <fieldset>
            <legend class="screen-reader-text"><span>Remove Injected CSS for comment widget</span></legend>
            <label for="<?php echo $this->plugin_name; ?>-comments_css_cleanup">
                <input type="checkbox" id="<?php echo $this->plugin_name; ?>-comments_css_cleanup" name="<?php echo $this->plugin_name; ?>[comments_css_cleanup]" value="1" <?php checked($comments_css_cleanup, 1); ?> />
                <span><?php esc_attr_e('Remove Injected CSS for comment widget', $this->plugin_name); ?></span>
            </label>
        </fieldset>

        <!-- remove injected CSS from gallery -->
        <fieldset>
            <legend class="screen-reader-text"><span>Remove Injected CSS for galleries</span></legend>
            <label for="<?php echo $this->plugin_name; ?>-gallery_css_cleanup">
                <input type="checkbox" id="<?php echo $this->plugin_name; ?>-gallery_css_cleanup" name="<?php echo $this->plugin_name; ?>[gallery_css_cleanup]" value="1" <?php checked( $gallery_css_cleanup, 1 ); ?>  />
                <span><?php esc_attr_e('Remove Injected CSS for galleries', $this->plugin_name); ?></span>
            </label>
        </fieldset>

        <!-- add post,page or product slug class to body class -->
        <fieldset>
            <legend class="screen-reader-text"><span><?php _e('Add Post, page or product slug to body class', $this->plugin_name); ?></span></legend>
            <label for="<?php echo $this->plugin_name; ?>-body_class_slug">
                <input type="checkbox" id="<?php echo $this->plugin_name; ?>-body_class_slug" name="<?php echo $this->plugin_name; ?>[body_class_slug]" value="1" <?php checked($body_class_slug, 1); ?>  />
                <span><?php esc_attr_e('Add Post slug to body class', $this->plugin_name); ?></span>
            </label>
        </fieldset>

        <!-- load jQuery from CDN -->
        <fieldset>
            <legend class="screen-reader-text"><span><?php _e('Load jQuery from CDN instead of the basic wordpress script', $this->plugin_name); ?></span></legend>
            <label for="<?php echo $this->plugin_name; ?>-jquery_cdn">
                <input type="checkbox"  id="<?php echo $this->plugin_name; ?>-jquery_cdn" name="<?php echo $this->plugin_name; ?>[jquery_cdn]" value="1" <?php checked($jquery_cdn,1); ?>/>
                <span><?php esc_attr_e('Load jQuery from CDN', $this->plugin_name); ?></span>
            </label>
            <fieldset>
                <p>You can choose your own cdn provider and jQuery version(default will be Google Cdn and version 1.11.1)-Recommended CDN are <a href="https://cdnjs.com/libraries/jquery">CDNjs</a>, <a href="https://code.jquery.com/jquery/">jQuery official CDN</a>, <a href="https://developers.google.com/speed/libraries/#jquery">Google CDN</a> and <a href="http://www.asp.net/ajax/cdn#jQuery_Releases_on_the_CDN_0">Microsoft CDN</a></p>
                <legend class="screen-reader-text"><span><?php _e('Choose your prefered cdn provider', $this->plugin_name); ?></span></legend>
                <input type="url" class="regular-text" id="<?php echo $this->plugin_name; ?>-cdn_provider" name="<?php echo $this->plugin_name; ?>[cdn_provider]" value="<?php if(!empty($cdn_provider)) echo $cdn_provider; ?>"/>
            </fieldset>
        </fieldset>

        <?php submit_button('Save all changes', 'primary','submit', TRUE); ?>

So what we’re doing is basically checking to see if the value exists already, and, if it does, populating the input field with the current value.

We do this by first grabbing all our options and assigning each one to a variable (try to keep those explicit so you know which is which).

Then we add a small condition. We will use the WordPress built-in checked function on our inputs to get the saved value and add the “checked” attribute if the option exists and is set to 1.

So save your file, try to save your plugin once last time, and, boom!, we have successfully finished our plugin.

Conclusion

We have seen a lots of things. From the benefits of creating your own plugin and sharing it with fellow WordPress users to why you might want to make your repetitive functions into a plugin. We have reviewed the incredible WordPress Plugin Boilerplate, its structure, and why you should definitely use it.

We put our hands in the grease and pushed ourselves in the first steps of doing a plugin, with 2 types of fields validation and sanitization, all that keeping a Oriented Object PHP process with clean and explicit code. We’re not finished yet though.

In part 2, we will make our plugin alive, creating the functions that will actually influence your WordPress website, we will also discover more complex field types and sanitization, and, finally, get our plugin ready to be reviewed by the WordPress repository team.

We’ll wrap this up with some additional links and sources:

Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

Learn more about us


About the authors
Default avatar
Guillaume Kanoufi

author

Still looking for an answer?

Ask a questionSearch for more help

Was this helpful?
 
Leave a comment


This textbox defaults to using Markdown to format your answer.

You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!

Try DigitalOcean for free

Click below to sign up and get $200 of credit to try our products over 60 days!

Sign up

Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

Get our biweekly newsletter

Sign up for Infrastructure as a Newsletter.

Hollie's Hub for Good

Working on improving health and education, reducing inequality, and spurring economic growth? We'd like to help.

Become a contributor

Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.

Welcome to the developer cloud

DigitalOcean makes it simple to launch in the cloud and scale up as you grow — whether you're running one virtual machine or ten thousand.

Learn more
DigitalOcean Cloud Control Panel