Building a Python Tips API with Django REST Framework and Deploying it to Digital Ocean

Bob, Tue 05 March 2019, Django

APIs, BeautifulSoup, curl, deployment, Digital Ocean, Django, Django Commands, Django REST Framework, Gunicorn, Linux, Nginx, postgres, Postman, requests, SSH, tips

In this article I will show you how to build a simple API for our growing collection of Python tips:

Sounds exciting? You bet it is! Let's jump straight in!


If you want to follow along, the final code is here.

First we make a virtual environment and pip install the requirements which include:

So here we go:

[[email protected] code]$ mkdir tips_api && cd $_
[[email protected] tips_api]$ alias pvenv
alias pvenv='/Library/Frameworks/Python.framework/Versions/3.7/bin/python3.7 -m venv venv && source venv/bin/activate'
[[email protected] tips_api]$ pvenv
(venv) [[email protected] tips_api]$ python -V
Python 3.7.0
(venv) [[email protected] tips_api]$ pip install -r requirements.txt

Let's set the following 2 environment variables as referenced in Django's file later on:

(venv) [[email protected] tips_api (master)]$ deactivate
[[email protected] tips_api (master)]$ vi venv/bin/activate
export SECRET_KEY='some-secret-string'
export DEBUG=True

Then activate the venv:

[[email protected] tips_api (master)]$ source venv/bin/activate
(venv) [[email protected] tips_api (master)]$

It's also important to set ALLOWED_HOSTS, but as we will see later I have it default to localhost which is fine for my local env.

Create a Django project and app

Let's make a Django project in the current directory (.):

(venv) [[email protected] tips_api]$ startproject tips .

Ignoring my virtual env folder, Django created this minimalistic project folder structure, tips being our main app:

$ tree -I venv
├── requirements.txt
└── tips

1 directory, 6 files

And let's make our api app to keep it isolated from the rest:

(venv) [[email protected] tips_api]$ startapp api
(venv) [[email protected] tips_api]$ tree -I venv
├── api
   ├── migrations

Lastly we need to tell Django about both apps in tips/ -> INSTALLED_APPS. We also add rest_framework here for later use:


The model

Looking at the original model on our platform we see the following fields:


As we saw last time the share_link is to be populated by an admin when we share out a tip with a nice carbon image.

Let's build our model tips/ using these fields.

I am also creating an Author model to store Twitter and Slack handles from future contributing users. The former to give credit when we share their tips on Twitter, the latter to store the author of the tip when we start receiving them from Slack (next article).

Thinking about (predicting) tomorrow's requirement is one of the fascinating things about software development!

from django.contrib.auth.models import User
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver

class Tip(models.Model):
    tip = models.TextField()
    code = models.TextField(blank=True, null=True)
    link = models.URLField(blank=True, null=True)

    # set by admin
    approved = models.BooleanField(default=False)
    share_link = models.URLField(blank=True, null=True)

    added = models.DateTimeField(auto_now_add=True)
    edited = models.DateTimeField(auto_now=True)
    user = models.ForeignKey(User, on_delete=models.CASCADE)

class Author(models.Model):
    """Extending the standard User model:
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    slack_handle = models.CharField(max_length=30, unique=True)
    twitter_handle = models.CharField(max_length=30)

@receiver(post_save, sender=User)
def create_user_author(sender, instance, created, **kwargs):
    if created:

@receiver(post_save, sender=User)
def save_user_author(sender, instance, **kwargs):

The Tip model should be pretty straightforward. Note the User ForeignKey in Tip to give them owners.

I added @receivers to Django's native User model to detect when a new user gets created. When that happens a new Author instance is created as well.

This is one way to extend Django's User model which I learned about here.

Now it's time to make the migration files and migrate (sync) the two new models to the database.

As it's the first time we call migrate all models that come with Django out of the box, get synced to the DB as well. I am using the default sqlite3 DB for now, but I should probably change that to Postgres to mirror deployment to Digital Ocean.

(venv) [bobbelderbos@imac tips_api (master)]$ python makemigrations tips
Migrations for 'tips':
    - Create model Author
    - Create model Tip

(venv) [bobbelderbos@imac tips_api (master)]$ python migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, sessions, tips
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying sessions.0001_initial... OK
Applying tips.0001_initial... OK

Let's create a superuser pybites to access the app and see if the Author entry was created as well

$ python createsuperuser
Username (leave blank to use 'bobbelderbos'): pybites
Email address:
Password (again):
Superuser created successfully.

Great: that created both a User and an Author instance in our DB. I am using DB Browser for SQLite to query my local DB here:

auth user

tips author

Import Tips from our Platform

Next we want to retrieve the tips currently on our platform so I coded up a Django command to parse them and save them to our new tips Database.

(venv) [[email protected] tips_api (master)]$ cd tips/
(venv) [[email protected] tips (master)]$ mkdir -p management/commands
(venv) [[email protected] tips (master)]$ cd $_
(venv) [[email protected] commands (master)]$ vi

Here is the code I mostly re-used from last week's article: Generating Beautiful Code Snippets with Carbon and Selenium:

import sys

from bs4 import BeautifulSoup
import requests

from django.contrib.auth.models import User
from import BaseCommand

from tips.models import Tip

PYBITES = 'pybites'

class Command(BaseCommand):
    """Quick and dirty, using bs4 from last article:

    About django-admin commands:
    help = 'Script to insert pybites tips from platform'

    def handle(self, *args, **options):
            user = User.objects.get(username=PYBITES)
        except User.DoesNotExist:
            error = 'Cannot run this without SU pybites'

        html = requests.get(TIPS_PAGE)
        soup = BeautifulSoup(html.text, 'html.parser')
        trs = soup.findAll("tr")

        new_tips_created = 0
        for tr in trs:
            tds = tr.find_all("td")
            tip_html = tds[1]

            links = tip_html.findAll("a", class_="left")
            first_link = links[0].attrs.get('href')

            pre = tip_html.find("pre")
            code = pre and pre.text or None

            share_link = None
            if PYBITES_HAS_TWEETED in first_link:
                share_link = first_link

            tip = tip_html.find("blockquote").text
            src = len(links) > 1 and links[1].attrs.get('href') or None

            _, created = Tip.objects.get_or_create(tip=tip, code=code,
                                                   link=src, user=user,

            if created:
                new_tips_created += 1

        print(f'Done: {new_tips_created} tips imported')

It scrapes the page with requests and BeautifulSoup. Originally I kept a list of Tip objects and used Django's ORM bulk_create method to write them to the DB in one transaction which is neat.

However I found it nicer to make it work like an update script, in case the platform gets new tips in the interim. Calling the script again should just insert the newer tips.

For that reason I went with Django's handy Tip.objects.get_or_create which will create the tip if nothing matches the kwargs passed in, otherwise it will return the object (which I ignore using _). This way it will only create a tip if not already in the DB.

Let's run this:

(venv) [[email protected] tips_api (master)]$ python sync_tips
Done: 92 tips imported

tips imported

a single tip

Awesome: we have our tips in the DB and you now know how to write a Django Command! Just extend BaseCommand, implement handle and save the script in a subdirectory called management/commands in your app folder.

Build an API with Django REST Framework

Let's build the API next. We already added rest_framework to INSTALLED_APPS. Let's also set the permissions to DjangoModelPermissionsOrAnonReadOnly which seems to match what we want:

Similar to DjangoModelPermissions (permission class ties into Django's standard django.contrib.auth model permissions), but also allows unauthenticated users to have read-only access to the API.

In tips/ I am adding the REST_FRAMEWORK dict, setting DEFAULT_PERMISSION_CLASSES:

    # Use Django's standard `django.contrib.auth` permissions,
    # or allow read-only access for unauthenticated users.


Next we need a serializer which Django REST defines as:

Serializers allow complex data such as querysets and model instances to be converted to native Python datatypes that can then be easily rendered into JSON, XML or other content types. Serializers also provide deserialization, allowing parsed data to be converted back into complex types, after first validating the incoming data.

In api/ I added this code which should look familiar if you have worked with the Django Form class:

from rest_framework import serializers

from tips.models import Tip

class TipSerializer(serializers.ModelSerializer):
    author = serializers.RelatedField(read_only=True)

    class Meta:
        model = Tip
        fields = ('tip', 'code', 'link', 'author', 'approved', 'share_link')

You probably don't have to define fields if you want them all, but being explicit is usually not a bad thing.


Next the views. Again Django REST Framework's great level of abstraction saves us a lot of boilerplate code. We can just extend some useful classes defined in generics:

from rest_framework import generics

from tips.models import Tip
from .serializers import TipSerializer
from .permissions import IsOwnerOrReadOnly

class TipList(generics.ListCreateAPIView):
    Return a list of all tips in the DB.

    Create an awesome new tip.
    queryset = Tip.objects.all()
    serializer_class = TipSerializer

class TipDetail(generics.RetrieveUpdateDestroyAPIView):
    Return an individual tip.

    Update an existing tip.

    Delete a single tip.
    permission_classes = (IsOwnerOrReadOnly, )
    queryset = Tip.objects.all()
    serializer_class = TipSerializer

Note that I structured the docstrings in a way to easily add documentation using a tool like Swagger in the future.


In the TipDetail view we defined a customized permission class called IsOwnerOrReadOnly which we need to define next in api/ (code from second example here).

The following code in that module will grant read access to any safe methods (GET, OPTIONS and HEAD) and edit right for users that have authored ("own") tips.

from rest_framework import permissions

class IsOwnerOrReadOnly(permissions.BasePermission):
    Make sure only owners of tips can edit them.

    def has_object_permission(self, request, view, obj):
        # Read permissions are allowed to any request,
        # so we'll always allow GET, HEAD or OPTIONS requests.
        if request.method in permissions.SAFE_METHODS:
            return True

        # Instance must have an attribute named `owner`.
        return obj.user == request.user

obj.user refers to the ForeignKey we defined on the Tip model:

class Tip(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)


And finally to be able to access the routes let's set up our urlpatterns in api/

from django.urls import path, include

from .views import TipList, TipDetail

urlpatterns = [
    path('', TipList.as_view()),
    path('<int:pk>', TipDetail.as_view()),
    path('admin/', include('rest_framework.urls')),

And in the main app's router file (tips/ include the api one like this:

from django.urls import path, include

urlpatterns = [
    path(r'api/', include('api.urls')),

And that's it! Let's launch the server and check out our new API next ...

(venv) [[email protected] tips_api (master)]$ python runserver
Starting development server at

Verify it works

We can consume our API with various tools:


Nice and easy:

[bobbelderbos@imac ~]$ curl
{"tip":"Q: difference between __str__ and __repr__ in #Python? A: \"My rule of thumb:  __repr__ is for developers, __str__ is for customers.\" (Ned Batchelder on SO)","code":null,"link":"","user":1,"approved":true,"share_link":null}


Postman Simplifies API Development. You can download it here.

Get all tips:

get all tips

Get a single tip:

get single tip

Verify I cannot post without login:

cannot post

Django REST Framework

Django REST provides a really nice browser interface to play with our new API:

Django rest front-end

As I am an anonymous user I don't see any edit options:

Django rest front-end

When I log in as my superuser though, a form and delete button show up:

Django rest front-end

Navigating back to the root of the API, I can also POST a new tip now that I am logged in:

Django rest front-end


Ideally we'd write some tests at this point to future-proof any changes we have to make to our API. I leave that as an exercise (challenge) for the reader though. You can PR your code for this challenge here. We did a similar exercise here.

Deploy the API to Digital Ocean

I got 100 bucks credit for free via Python Bytes - nice, thanks!

Fresh install

My first attempt (cause programmers are supposed to be lazy, right?!) was to go with the Django One-Click Application:

Django droplet

Django droplet

However the latest Django from APT is 1.11 at this time and our API uses Django 2.x so let's set everything up from scratch using a fresh Ubuntu 18.04 server instance. Not only do we get it exactly as we want, it's also a nice learning exercise!

new Ubuntu instance

SSH access and keys

First hurdle you might find is not being able to SSH in as root (I should have remembered this from my old SunOS days ...):

$ ssh root@
root@ Permission denied (publickey).

PermitRootLogin was already set to yes in /etc/ssh/sshd_config, but I also had to set PasswordAuthentication to yes opening a Console session from Digital Ocean's BUI, followed by a sudo service ssh restart. Now I was able to SSH in as root.

I uploaded a SSH public key to Digital Ocean's account page and had it added to the droplet upon creaton. This populated ~/.ssh/authorized_keys with my public key.

Locally I then made an alias in .ssh/config to be able to login using the alias ssh dioc:

Host dioc
User root
IdentityFile ~/.ssh/DO

(Where /.ssh/DO is my private key stored locally.)


Best practice is to first upgrade the OS because it was behind on important security fixes. This required a reboot:

root@ubuntu-s-1vcpu-1gb-nyc1-01:~# apt-get update && apt-get -y upgrade
root@ubuntu-s-1vcpu-1gb-nyc1-01:~# reboot

Logging in again I followed Digital Ocean's Initial Server Setup with Ubuntu 18.04 creating a user: adduser bob and granting sudo rights: usermod -aG sudo bob


Next I enabled the firewall:

root@ubuntu-s-1vcpu-1gb-nyc1-01:~# ufw app list
Available applications:
root@ubuntu-s-1vcpu-1gb-nyc1-01:~# ufw allow OpenSSH
Rules updated
Rules updated (v6)
root@ubuntu-s-1vcpu-1gb-nyc1-01:~# ufw enable
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup
root@ubuntu-s-1vcpu-1gb-nyc1-01:~# ufw status
Status: active

To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW       Anywhere
OpenSSH (v6)               ALLOW       Anywhere (v6)

SSH part II

Next I made sure my new user (bob) could login as well.

As I use SSH keys for logging in as root, I copied over the .ssh folder to my new user's home folder using rsync:

root@ubuntu-s-1vcpu-1gb-nyc1-01:~# rsync --archive --chown=bob:bob ~/.ssh /home/bob
root@ubuntu-s-1vcpu-1gb-nyc1-01:~# ls -l  /home/bob/.ssh/authorized_keys
-rw------- 1 bob bob 410 Mar  3 07:40 /home/bob/.ssh/authorized_keys

And locally I made a new SSH shortcut in .ssh/config:

Host diocu
User bob
IdentityFile ~/.ssh/DO

At this point bob could SSH in using the alias ssh diocu:

(venv) [bobbelderbos@imac tips_api (master)]$ ssh diocu
Welcome to Ubuntu 18.04.2 LTS (GNU/Linux 4.15.0-45-generic x86_64)
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.


Next I followed Digital Ocean's useful article: How To Set Up Django with Postgres, Nginx, and Gunicorn on Ubuntu 18.04.

This guide shows you how to install Django within a virtual environment which I think is the best way to go, Namespaces are one honking great idea -- let's do more of those! (Zen of Python)

What follows is mostly the same as the steps outlined in that guide, but through the lens of me doing it for our API.

Python 3

First we need to install the following packages, upgrade pip and grab virtualenv. Note I logged in as bob so any admin commands need to be preceded by sudo:

sudo apt update
sudo apt install python3-pip python3-dev libpq-dev postgresql postgresql-contrib nginx curl
sudo -H pip3 install --upgrade pip
sudo -H pip3 install virtualenv


We want to use a postgres DB and user:

sudo -u postgres psql
postgres=# CREATE DATABASE tips;
postgres=# CREATE USER pybites WITH PASSWORD 'something-secure';
postgres=# ALTER ROLE pybites SET client_encoding TO 'utf8';
postgres=# ALTER ROLE pybites SET default_transaction_isolation TO 'read committed';
postgres=# ALTER ROLE pybites SET timezone TO 'UTC';
postgres=# GRANT ALL PRIVILEGES ON DATABASE tips TO pybites;
postgres=# \q

Pull in the Django API code

Not part of the guide, but at this point I pulled in our code from Github saving it to my $HOME folder:

git clone
cd tips_api


I added this to the venv's activation script, to run the dev server (gunicorn won't use these though as we will see in a bit):

$ vi venv/bin/activate
export SECRET_KEY='xyz'
export DEBUG=True
export ALLOWED_HOSTS='localhost,'
export DB_ENGINE='django.db.backends.postgresql_psycopg2'
export DB_NAME='tips'
export DB_USER='pybites'
export DB_PASSWORD='something-secure'

$ source venv/bin/activate

These are referenced in Django's tips/ with sensible defaults if omitted:

ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost').split(', ')
    'default': {
        'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.sqlite3'),  # default sqlite3 if desired
        'NAME': os.environ.get('DB_NAME', os.path.join(BASE_DIR, 'db.sqlite3')),
        'USER': os.environ.get('DB_USER', ''),
        'PASSWORD': os.environ.get('DB_PASSWORD', ''),
        'HOST': os.environ.get('DB_HOST', 'localhost'),
        'PORT': os.environ.get('DB_PORT', ''),  # can leave empty on Digital Ocean


With my venv enabled let's install the dependencies:

(venv) bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ pip install -r requirements.txt
(venv) bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ pip freeze

Static files

To support static file handling by Nginx we need to set the following two constants in tips/

STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static/')

Sync the DB

Next we sync the models/migrations to the newly created postgres DB:

(venv) bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ python migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, sessions, tips
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying sessions.0001_initial... OK
Applying tips.0001_initial... OK
Applying tips.0002_auto_20190302_1958... OK
Applying tips.0003_auto_20190302_2011... OK


Note that Django migrations are committed to source code so I did not have to generate them (makemigrations) again!


Let's create our pybites admin to pull in our existing tips:

(venv) bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ python createsuperuser
Username (leave blank to use 'bob'): pybites
Email address:
Password (again):
Superuser created successfully.

And import the tips from our platform:

(venv) bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ python sync_tips
Done: 92 tips imported

Perfect. Let's collect the static files (but it's an API? you might ask ... well, Django's Admin back-end and Django REST Framework's browser UI use quite a few static files!)

(venv) bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ python collectstatic
155 static files copied to '/home/bob/tips_api/static'.

(venv) bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ ls static/
admin  rest_framework

Dev server

To run the development server have the firewall grant access to port 8000:

(venv) bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ sudo ufw allow 8000
[sudo] password for bob:
Rule added
Rule added (v6)

And now we can run the development server:

(venv) bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ python runserver
Performing system checks...

System check identified no issues (0 silenced).
March 04, 2019 - 09:05:00
Django version 2.1.7, using settings 'tips.settings'
Starting development server at
Quit the server with CONTROL-C.

Browsing to we see:

api from dev server

Awesome! Well, actually first I got a permission error because I only had localhost in ALLOWED_HOSTS so make sure you add your own IP. Again in this case that is


Now let's use Gunicorn. First test it manually:

(venv) bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ gunicorn --bind tips.wsgi
[2019-03-04 09:11:10 +0000] [16579] [INFO] Starting gunicorn 19.9.0
[2019-03-04 09:11:10 +0000] [16579] [INFO] Listening at: (16579)
[2019-03-04 09:11:10 +0000] [16579] [INFO] Using worker: sync
[2019-03-04 09:11:10 +0000] [16582] [INFO] Booting worker with pid: 16582

Next we make it persistent with a socket:

The Gunicorn socket will be created at boot and will listen for connections. When a connection occurs, systemd will automatically start the Gunicorn process to handle the connection.

bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ sudo vi /etc/systemd/system/gunicorn.socket

Add this:

Description=gunicorn socket



Then create a gunicorn.service:

bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ sudo vi /etc/systemd/system/gunicorn.service


Description=gunicorn daemon

ExecStart=/home/bob/tips_api/venv/bin/gunicorn \
        -e SECRET_KEY='abc' \
        -e DEBUG=True \
        -e ALLOWED_HOSTS='localhost,' \
        -e DB_ENGINE='django.db.backends.postgresql_psycopg2' \
        -e DB_NAME='tips' \
        -e DB_USER='pybites' \
        -e DB_PASSWORD='abc' \
        --access-logfile - \
        --workers 3 \
        --bind unix:/run/gunicorn.sock \


And here I had some trouble: Gunicorn did not pick up env variables so Django would not get its required SECRET_KEY etc.

As per this thread I tried EnvironmentFile=/home/bob/.env copying the variables from venv/bin/activate (argh). Even that did not work.

So I ended up with adding them with multiple -e flags making it less DRY, although as all runs through gunicorn we might just ditch them from venv/bin/activate altogether.

To set this in motion:

bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ sudo systemctl start gunicorn.socket
bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ sudo systemctl enable gunicorn.socket
Created symlink /etc/systemd/system/  /etc/systemd/system/gunicorn.socket.

bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ sudo systemctl status gunicorn.socket
Failed to dump process list, ignoring: No such file or directory
 gunicorn.socket - gunicorn socket
Loaded: loaded (/etc/systemd/system/gunicorn.socket; enabled; vendor preset: enabled)
Active: active (listening) since Mon 2019-03-04 09:18:06 UTC; 21s ago
Listen: /run/gunicorn.sock (Stream)
CGroup: /system.slice/gunicorn.socket

Mar 04 09:18:06 ubuntu-s-1vcpu-1gb-nyc1-01 systemd[1]: Listening on gunicorn socket.

bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ file /run/gunicorn.sock
/run/gunicorn.sock: socket

By the way, when troubleshooting these kind of issues and when making changes to your gunicorn config, remember to restart both the daemon and the gunicorn service:

bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ sudo systemctl daemon-reload && sudo systemctl restart gunicorn.socket gunicorn.service


As per Digital Ocean's guide I needed to do the following to get Nginx running:

bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ sudo vi /etc/nginx/sites-available/tips_api

server {
    listen 80;

    location = /favicon.ico { access_log off; log_not_found off; }
    location /static/ {
        root /home/bob/tips_api;

    location / {
        include proxy_params;
        proxy_pass http://unix:/run/gunicorn.sock;

bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ sudo ln -s /etc/nginx/sites-available/tips_api /etc/nginx/sites-enabled
bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ sudo systemctl restart nginx
bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ sudo ufw delete allow 8000
Rule deleted
Rule deleted (v6)
bob@ubuntu-s-1vcpu-1gb-nyc1-01:~/tips_api$ sudo ufw allow 'Nginx Full'
Rule added
Rule added (v6)

I ran into a weird error after this:

Mar 04 10:06:18 ubuntu-s-1vcpu-1gb-nyc1-01 systemd[1]: nginx.service: Failed to parse PID from file /run/ Invalid argument

... which I could resolve using this workaround, but it was overshadowed by the Gunicorn ENV variable issue I described earlier, so not sure if this would have been a stopper.

And voilà: we have our PyBites API hosted on Digital Ocean!

API on Digital Ocean


This was a fun exercise! We managed to turn PyBites Tips into its own service hosted in the cloud!

The only challenge was gunicorn not picking up environment variables from a file, but we got around that and it all works nicely now ...

Now go build your own API with the Django REST Framework and PR us your code here.

I will do a follow-up article on how to use the Slack API and its Slash Commands feature to POST tips to this API from our Slack.

By the way, PyBites Slack is a really cool place to hang out, you can join us here.

Keep Calm and Code in Python!

-- Bob

PyBites Python Tips

Do you want to get 250+ concise and applicable Python tips in an ebook that will cost you less than 10 bucks (future updates included), check it out here.

Get our Python Tips Book

"The discussions are succinct yet thorough enough to give you a solid grasp of the particular problem. I just wish I would have had this book when I started learning Python." - Daniel H

"Bob and Julian are the masters at aggregating these small snippets of code that can really make certain aspects of coding easier." - Jesse B

"This is now my favourite first Python go-to reference." - Anthony L

"Do you ever go on one of those cooking websites for a recipe and have to scroll for what feels like an eternity to get to the ingredients and the 4 steps the recipe actually takes? This is the opposite of that." - Sergio S

Get the book