Deploying Apostrophe on Docker

In this tutorial, we'll deploy an Apostrophe 2 website with Portainer, Nginx Proxy Manager, and Watchtower for an easy-to-manage Docker experience.

apostrophe on docker header

Before we get started, you might be wondering why you should deploy your Apostrophe website using Docker. Docker allows developers to deploy applications while keeping each part of the application separate. In this guide, we're going to create one custom Docker image for our app but use five other images as well:

  • Apostrophe - Our custom Apostrophe website image
  • Mongo - Database for our Apostrophe app
  • Portainer - GUI for managing Docker
  • Nginx Proxy Manager - Manages NGINX and Let's Encrypt certificates
  • MariaDB - Database for Nginx Proxy Manager
  • Watchtower - Auto-updates images

Note: While advancements for Apostrophe 3 continue, this tutorial is focused specifically on deploying Apostrophe 2 on Docker. There are plans to document and share updates on this same topic for Apostrophe 3 in the future. 

Setting up the Server

For this example, I'll be using a VPS on DigitalOcean running RancherOS with 2 CPUs and 2GB RAM. RancherOS a lightweight OS designed for hosting containers. If you'd prefer to use another distribution, you'll need to make sure Docker Engine is installed. Instructions for installing Docker Engine can be found in the Docker documentation. Any cloud provider will work just fine but I'd suggest the specs above as minimums.

rancheros

Log into the server using rancher@<server_ip> so we can start provisioning!

Install Portainer

Next we're going to install Portainer. Portainer provides a gui for Docker. While it isn't technically necessary, it makes using Docker a little nicer.

Create a volume for portainer.

docker volume create portainer_data

Run the container.

docker run -d -p 8000:8000 -p 9000:9000 --name=portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce:alpine

Now you can access Portainer in the browser at <ip>:9000. After visiting the page, create a user. On the next page, select Docker as the environment and click connect.

portainer.io

Now we're at the Portainer dashboard. To make links in Portainer work, we'll want to add our IP as the public IP. On the sidebar under settings, click "Endpoints" and then "local" under name in the table. Enter the IP of your server under "Public IP".

portainer ip

Creating and hosting a Docker Image

For this example, I'll be deploying a repo based on our boilerplate Apostrophe 2 repo. If you've started your project based on that repo, it should be easy to get up and running by looking at the Docker files (Dockerfile, docker-compose.yml, and .dockerignore) in the repo. Also, make sure the version of apostrophe is at least 2.116.1. If you want to follow along without deploying one of your projects, click the "Use this template" button.

We'll be hosting our Docker image over on DockerHub so you'll need to create an account over there if you don't yet have one. After you're logged in, click on "Create Repository". Then give your image a name, link it to a repo, and add a build rule, making sure to check that the source branch name is correct. This will tell DockerHub to build a new image whenever it sees an update to the linked branch. Finish by clicking "Create & Build".

💡Note: While DockerHub can host images for many different architectures, it only builds amd64 images. If you plan on deploying on an ARM processor, for example, you'll want to use something like this GitHub Action. Stay tuned for more info on how to do this in a future blog post!

 

Now we'll need to wait for the image to be built before we can use it. Once you see a green checkmark next to an item under "Recent Builds" of the image's page on DockerHub, we're ready to continue.

Deploying a Stack in Portainer

Time to deploy our Apostrophe site! Log back into Portainer (<ip>:9000), then click "Stacks" in the sidebar, and "+ Add Stack" at the top of the main section of the page. Now, give your stack a name. I'm going to use apostrophe. Next, we'll want to paste in the contents of a docker-compose.yml with a couple updates. The file in the boilerplate repo is made for use in development but now we have a built image hosted on DockerHub. So we'll need to delete build: . under the apostrophe service and add image: <your-dockerhub-handle>/<your-dockerhub-repo> instead.

Also, I'd recommend tagging mongo with the latest major and minor version, which is 4.4 at the time of writing. This can always be updated manually later but will prevent your database from unexpectedly breaking. Here's the config I'm using:

version: "2"
services:
  apostrophe:
    image: <your-dockerhub-handle>/<your-dockerhub-repo>
    restart: always
    ports:
      - "3000:3000"
    volumes:
      - ./data/uploads:/app/public/uploads
    environment:
      - APOS_MONGODB_URI=mongodb://mongo:27017/db
    depends_on:
      - mongo
  mongo:
    image: mongo:4.4
    restart: always
    volumes:
      - ./data/db:/data/db
docker hub 2

💡 If you're new to Docker it's worth noting that the mongo portion of the APOS_MONGODB_URI is a reference to the mongo container name. Since Docker also handles networking, Docker will replace this with the IP of the mongo container name. We'll also use this when setting up the Nginx container.

 

Once you're done, click "Deploy the stack" at the bottom of the page. After waiting for the deploy to finish, you should be able to see the Apostrophe site at <ip>:3000! 🎉

SSH to Add Admin User

Now that we've deployed our Apostrophe application, we'll want to add a user. We can do this by clicking on the >_ icon under quick actions next to the apostrophe container.

portainer 2

Next, click "Connect" and we're in. Now we can run node app.js apostrophe-users:add admin admin to create an admin user. Enter a password and we're done. We can visit <ip>:3000/login to use the login we just created.

Configuring Nginx with Let's Encrypt

Now that Apostrophe is setup, time to connect a domain and setup Nginx. To do this we'll use Nginx Proxy Manager - a GUI for Nginx with support for Let's Encrypt.

Before we start setting that up, we'll want to create some A records that point to our server. I'd suggest creating three - one for each service (Apostrophe, Portainer, and Nginx).

First, add a new stack in Portainer by clicking on "Stacks" in the sidebar and then "+ Add Stack". Name the stack npm (short for for nginx proxy manager, not what you're thinking of) and copy in the contents below. Then replace the password fields with the passwords you would like to use and click "Deploy the stack".

version: "2"
services:
  npm:
    container_name: npm
    image: jc21/nginx-proxy-manager:2
    restart: always
    networks:
      - default
      - proxy_network
    ports:
      - '80:80'
      - '443:443'
      - '81:81'
    environment:
      DB_MYSQL_HOST: "mariadb"
      DB_MYSQL_PORT: 3306
      DB_MYSQL_USER: "npm"
      DB_MYSQL_PASSWORD: "<good-password>"
      DB_MYSQL_NAME: "npm"
    volumes:
      - ./data/npm:/data
      - ./data/letsencrypt:/etc/letsencrypt
    depends_on:
      - mariadb
  mariadb:
    image: mariadb:10.5
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: '<good-root-password>'
      MYSQL_DATABASE: 'npm'
      MYSQL_USER: 'npm'
      MYSQL_PASSWORD: '<good-password>'
    volumes:
      - ./data/mysql:/var/lib/mysql
networks:
  default:
  proxy_network:
    driver: bridge

Now we should have a successfully deployed Nginx stack. Before we configure Nginx, we'll need to add our Portainer and Apostrophe containers to the Nginx container's proxy network. For Apostrophe, click "Stacks" in the sidebar, then apostrophe, then "Editor". We're going to update it to match the config below.

version: "2"
services:
  apostrophe:
    image: <your-dockerhub-handle>/<your-dockerhub-repo>
    restart: always
    networks:
      - default
      - proxy_network
    ports:
      - "3000:3000"
    volumes:
      - ./data/uploads:/app/public/uploads
    environment:
      - APOS_MONGODB_URI=mongodb://mongo:27017/db
    depends_on:
      - mongo
  mongo:
    image: mongo:4.4
    restart: always
    volumes:
      - ./data/db:/data/db
networks:
  default:
  proxy_network:
    external:
      name: npm_proxy_network

To update the Portainer container, we'll need to remove the existing Portainer container and start a new one to add it to the Nginx container's network. To do so we'll need to ssh into the server again. After that, run docker ps -a to see all containers. You should see something like the output below.

dockerroot@a3-docker-demo:~# docker ps -a
CONTAINER ID   IMAGE                                           COMMAND                  CREATED          STATUS                    PORTS                                            NAMES
781e3780d22b   <your-dockerhub-handle>/<your-dockerhub-repo>   "docker-entrypoint.s…"   12 minutes ago   Up 13 seconds             3000/tcp                                         apostrophe
af894847fb7d   mongo:4.4                                       "docker-entrypoint.s…"   12 minutes ago   Up 12 minutes             27017/tcp                                        mongo
133d74e6b0a5   portainer/portainer-ce:alpine                   "/portainer"             24 minutes ago   Up 7 minutes              0.0.0.0:8000->8000/tcp, 0.0.0.0:9000->9000/tcp   portainer
7248f73385ac   jc21/nginx-proxy-manager:2                      "/init"                  3 hours ago      Up 24 minutes (healthy)   0.0.0.0:80-81->80-81/tcp, 0.0.0.0:443->443/tcp   npm
17d1d1d921f1   mariadb:10.5                                    "docker-entrypoint.s…"   3 hours ago      Up 3 hours                3306/tcp                                         mariadb

First, we'll need to stop and remove the Portainer container. We'll need to reference the container id of the Portainer container above. Run docker stop <portainer-container-id> then docker rm <portainer-container-id>. Then we'll start a new Portainer container with the same command as before except adding the Nginx network using docker run -d -p 8000:8000 -p 9000:9000 --name=portainer --restart=always --network="npm_proxy_network" -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce:alpine.

Now that we can continue configuring Nginx, visit <ip>:81. The default login is admin@example.com and changeme. After logging in, you'll be prompted to change it. Now that we're logged in, click "Dashboard" at the top, then "0 Proxy Hosts", and "Add Proxy Host".

nginx proxy manager

This is how we can add each domain into Nginx. Under "Domain Names" type the name of the domain you'd like to use for Apostrophe and click the "add ..." dropdown. Then type apostrophe (the name of the docker container) under "Forward Hostname / IP" and 9000 under "Forward Port". Then click to the SSL tab and under the "SSL Certificate" dropdown, click "Request a new SSL Certificate". Toggle on "Force SSL" and "HTTP/2 Support", enter an email to use with Let's Encrypt, and toggle to agree to the Let's Encrypt ToS. Click "Save" and we're done! In the top right click "Add Proxy Host" and repeat this process for Apostrophe (port 3000) and Nginx proxy manager (port 81).

new proxy host

Optionally, we can do some cleanup to not publicly expose port 3000 for our Apostrophe app since it can be accessed via Nginx. Click "Stacks", the apostrophe stack, and then the "Editor" tab. Delete the ports: line and the line below it exposing port 3000. Similarly, if we would like to expose the mongo ports, that can be done by adding a couple lines including ports: under the apostrophe stack.

Auto-Updating Containers

The last step is to setup auto-update for our containers. Before doing this, it is important that tags are used correctly as noted in previous steps. Otherwise, by using the default latest tag, you will automatically get new major versions which could cause your app to break. So you'll want to make sure you aren't using latest tags for anything except your Apostrophe container which you control.

To add the Watchtower container, click "Containers" in the sidebar, then "+ Add container". Give it a name of watchtower and use the image containrrr/watchtower. Under "Advanced container settings" > "Volumes", click "+map additional volume". Set the top line to "Bind" and enter /var/run/docker.sock under both container and host. Then click the "Env" tab so we can add some environment variables. The list of all options can be found here. I like to add the variables TZ, WATCHTOWER_CLEANUP, WATCHTOWER_POLL_INTERVAL. For TZ, set the timezone you are in. This makes the time in the container logs match up with your timezone. For me, that's America/Los_Angeles. If you're not sure what to put, this Wikipedia page should help. For WATCHTOWER_CLEANUP I set it to true which automatically deletes old images after updating. Finally, WATCHTOWER_POLL_INTERVAL is the time (in seconds) that Watchtower checks for updates. I like to use 300 which is every 5 minutes. Now, click "Deploy the container".

Conclusion

We're done! Now you should have an Apostrophe website deployed with Docker Engine using Nginx (with Let's Encrypt certs) and Watchtower for keeping your containers running on the latest image. To take this a step further we could also add a staging Apostrophe stack for testing before production. It's also worth noting that this isn't a HA (highly available) deployment so if minimizing downtime is extremely important, you'll want to deploy your Docker containers on Kubernetes instead. That being said, this guide should be great for getting started with Docker and deploying small to medium sized websites.