How to Securely Deploy to Kubernetes from Bitbucket Pipelines

April 04, 2019

Over 100,000 GitHub repos have leaked API or cryptographic keys - ZDNet

Hands up if this has happened to you. You're reading a well-written article on one of countless topics, and you get to the line that goes something like this:

// DO NOT DO THIS IN A PRODUCTION APP
const API_KEY = '<api-key-displayed-in-plain-text>'

Ok, so how should you be doing this? Unfortunately, there isn't a one-size-fits-all approach to securing your secrets. Different programming languages deployed in different environments all handle secrets in their own way. 

Suffice it to say that you should never store secrets in your code or repository. Secrets should be passed into your app through environment variables at the last possible moment.

Bitbucket Pipelines - Continuous Delivery

I have been using Bitbucket Pipelines since it was in Alpha and I have to say, it's fantastic. It has to be the quickest and easiest way to setup continuous delivery right from your repo.

Pipelines are configured with YAML files and can be very simple or extremely complex depending on your needs.

Pipelines Configuration

I like to break up my build jobs into steps for a couple of reasons:

  • If a step fails, you can re-run individual steps.
  • Each step is isolated from the others. Only your base repo and any "artifacts" you declare will be passed to the next step.

Here is a 3-step bitbucket-pipelines.yml file that takes a create-react-app site, packages it as a Docker image and deploys it to a Kubernetes cluster:

options:
  # Enable docker for the Pipeline
  docker: true

pipelines:
  branches:
    master:
      - step:
          name: Build app for Production (create-react-app)
          image: mhart/alpine-node:10
          caches:
            - node
          script:
            # Install Dependencies
            - npm install
            # Run our Tests
            - npm run test
            # Package App for Production
            - npm run build
          artifacts:
            # Pass the "build" Directory to the Next Step
            - build/**
      - step:
          name: Build Docker Image
          script:
            # NOTE: Set $DOCKER_HUB_USERNAME and $DOCKER_HUB_PASSWORD as environment SECRETS in Bitbucket repository settings
            # Use $BITBUCKET_COMMIT to tag our docker image
            - export IMAGE_NAME=<docker-username>/<docker-image>:$BITBUCKET_COMMIT
            # Build the Docker image (this will use the Dockerfile in the root of the repo)
            - docker build -t $IMAGE_NAME .
            # Authenticate with the Docker Hub registry
            - docker login -u $DOCKER_HUB_USERNAME -p $DOCKER_HUB_PASSWORD
            # Push the new Docker image to the Docker registry
            - docker push $IMAGE_NAME
      - step:
          # trigger: manual
          name: Deploy to Kubernetes
          image: atlassian/pipelines-kubectl
          script:
            # NOTE: $KUBECONFIG is secret stored as a base64 encoded string
            # Base64 decode our kubeconfig file into a temporary kubeconfig.yml file (this will be destroyed automatically after this step runs)
            - echo $KUBECONFIG | base64 -d > kubeconfig.yml
            # Tell our Kubernetes deployment to use the new Docker image tag
            - kubectl --kubeconfig=kubeconfig.yml --namespace=<namespace> set image deployment/<deployment-name> <deployment-name>=<docker-username>/<docker-image>:$BITBUCKET_COMMIT

bitbucket-pipelines.yml

FROM mhart/alpine-node:10
WORKDIR /app
EXPOSE 5000

# Install http server
RUN yarn global add serve

# Bundle app source
COPY build /app/build

# Run serve
CMD [ "serve", "-n", "-s", "build" ]

Dockerfile

Here's the important part of all that:

- echo $KUBECONFIG | base64 -d > kubeconfig.yml

Our kubeconfig file is stored as a Base64 encoded string in a Bitbucket secret named $KUBECONFIG.

Bitbucket secrets are stored encrypted, and decrypted when you call the variable in pipelines.

We decode the $KUBECONFIG variable and store it in a temporary file called kubeconfig.yml which is automatically deleted as soon as this step completes.

Breaking it Down

Step 1

  1. Install dependencies
  2. Run tests
  3. Build
  4. Pass build directory to Step 2

Step 2

  1. Name Docker image
  2. Build Docker image
  3. Push image to Docker Hub

Step 3

  1. Decode kubeconfig
  2. Set deployment image

Build Performance

This entire build takes less than 1 minute 40 seconds and using Alpine Node the Docker image is just 29 MB.

Conclusion

Securing your secrets isn't hard, but it starts with knowing where to look. 

Some tips for securing secrets in different Node.js environments:

  • Node.js (Development): use .env files and .gitignore to keep .env files out of your repository.
  • Node.js (Production): use Kubernetes Secrets, Docker Secrets and pass as environment variables into the container.

Remember this one rule:

  • Don't store secrets in your code, your repository or your docker image.

Happy coding!