Making an Upload Service

Published: Mar 19, 2024 by Isaac Johnson

Recently, I posted about creating upload webapps and thought it might be fun to make my own. In this post, we’ll start with ReactJS and see where that takes us. Like before, the goal is to build something that can allow an end user to upload a file and then for us to be able to retrieve it later.

Build our own

Let’s whip up a quick ReactJS app.

I’ll use NodeJS 18 for this

builder@DESKTOP-QADGF36:~/Workspaces/reactUploader$ nvm use 18.18.2
Now using node v18.18.2 (npm v9.8.1)

I first started down the path of ReactJS.

builder@DESKTOP-QADGF36:~/Workspaces/reactUploader$ npx create-react-app webuploadtool

Creating a new React app in /home/builder/Workspaces/reactUploader/webuploadtool.

Installing packages. This might take a couple of minutes.
Installing react, react-dom, and react-scripts with cra-template...


added 1488 packages in 37s

253 packages are looking for funding
  run `npm fund` for details

Initialized a git repository.

Installing template dependencies using npm...

added 69 packages, and changed 1 package in 6s

257 packages are looking for funding
  run `npm fund` for details
Removing template package using npm...


removed 1 package, and audited 1557 packages in 2s

257 packages are looking for funding
  run `npm fund` for details

8 vulnerabilities (2 moderate, 6 high)

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.

Created git commit.

Success! Created webuploadtool at /home/builder/Workspaces/reactUploader/webuploadtool
Inside that directory, you can run several commands:

  npm start
    Starts the development server.

  npm run build
    Bundles the app into static files for production.

  npm test
    Starts the test runner.

  npm run eject
    Removes this tool and copies build dependencies, configuration files
    and scripts into the app directory. If you do this, you can’t go back!

We suggest that you begin by typing:

  cd webuploadtool
  npm start

Happy hacking!

However, after building out a decide File upload JS, I realized there just would not be a way in React to receive the fileContents in Docker without adding Express or some other API server.

import React, { useState } from 'react';


const FileUpload = () => {
  const [file, setFile] = useState(null);
  const [fileName, setFileName] = useState(null);

  const handleFileChange = (event) => {
    const selectedFile = event.target.files[0];
    setFile(selectedFile);
    setFileName(selectedFile.name);
  };

  const handleUpload = () => {
    if (!file) {
      alert('Please select a file first.');
      return;
    }

    const reader = new FileReader();

    reader.onload = () => {
      const fileContent = reader.result;
      // Code to upload the file content to /tmp on the container
      // This can vary depending on your backend/server setup.
      // You might need to make a POST request to your server to handle the file upload.
      // Example:
      // fetch('/upload', {
      //   method: 'POST',
      //   body: fileContent,
      // });
      console.log('File content:', fileContent);
      
    fetch('/uploadrec', {
        method: 'POST',
        body: fileContent,  
    });
      
    };

    reader.readAsText(file);
  };

  return (
    <div>
      <h2>File Uploader</h2>
      <input type="file" onChange={handleFileChange} />
      <button onClick={handleUpload}>Upload</button>
    </div>
  );
};

export default FileUpload;

That is a lot of code (and I’m just showing the fileUpload.js above skipping App.js and the rest) just for a UI.

Pivot to ExpressJS

I decided to stop and pivot to Express. For what I’m doing, it’s a lot simpler and can do serverside work.

$ npm init

I installed the libraries I would need

$ npm install --save express multer path fs

I could then create the server.js

// server.js
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');

const app = express();
const PORT = process.env.PORT || 3000;

// Set up multer to handle file uploads
const upload = multer({ dest: process.env.DESTINATION_PATH || '/tmp' });

// Serve the HTML form
app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'index.html'));
});

app.get('/simple.min.css', (req, res) => {
    res.sendFile(path.join(__dirname, 'simple.min.css'));
  });

// Handle file upload
app.post('/upload', upload.single('file'), (req, res) => {
  const tempFilePath = req.file.path;
  const originalFileName = req.file.originalname;
  const destinationFilePath = path.join(process.env.DESTINATION_PATH || '/tmp', originalFileName);

  fs.rename(tempFilePath, destinationFilePath, (err) => {
    if (err) {
      console.error('Error moving file:', err);
      res.status(500).send('Error moving file');
    } else {
      console.log('File saved successfully:', destinationFilePath);
      res.status(200).send('File uploaded successfully');
    }
  });
});

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

Note: I already parameterized the PORT and DESTINATION_PATH knowing I would need to be able to override them

I updated my ‘main’ file to use ‘server.js’ instead of ‘index.js’ in package.json

{
  "name": "webuploadtool3",
  "version": "1.0.0",
  "description": "Upload tool in expressjs",
  "main": "server.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "express"
  ],
  "author": "Isaac Johnson",
  "license": "MIT",
  "dependencies": {
    "express": "^4.18.2",
    "fs": "^0.0.1-security",
    "multer": "^1.4.5-lts.1",
    "path": "^0.12.7"
  }
}

My index.html is pretty straightforward except for importing the CSS

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" type="text/css" href="/simple.min.css"  />
  <title>File Upload</title>
</head>
<body>
  <h1>Upload a File</h1>
  <form action="/upload" method="post" enctype="multipart/form-data">
    <input type="file" name="file">
    <button type="submit">Upload</button>
  </form>
</body>
</html>

I downloaded the CSS from https://simplecss.org/

$ wget https://cdn.simplecss.org/simple.min.css

We could have used the HREF in the <HEAD> block but then we have an unneccessary external dependency

<!-- Minified version -->
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">

Let’s do a quick test

builder@DESKTOP-QADGF36:~/Workspaces/reactUploader/webuploadtool3$ npm start

> webuploadtool3@1.0.0 start
> node server.js

Server is running on port 3000

I’ll pick an image

/content/images/2024/03/expressupload-01.png

Click upload

/content/images/2024/03/expressupload-02.png

and see in the server log

builder@DESKTOP-QADGF36:~/Workspaces/reactUploader/webuploadtool3$ npm start

> webuploadtool3@1.0.0 start
> node server.js

Server is running on port 3000
File saved successfully: /tmp/downtown_berlin_corperate_office_building_clean_and_bright_2_78710595-fe99-4b58-984d-54eb5e32851d.png

I can kill the server and check

builder@DESKTOP-QADGF36:~/Workspaces/reactUploader/webuploadtool3$ ls -ltrah /tmp/downtown_berlin_corperate_office_building_clean_and_bright_2_78710595-fe99-4b58-984d-54eb5e32851d.png
-rw-r--r-- 1 builder builder 1.6M Feb 27 07:26 /tmp/downtown_berlin_corperate_office_building_clean_and_bright_2_78710595-fe99-4b58-984d-54eb5e32851d.png

I can also try setting a different root path and testing the same file upload:

builder@DESKTOP-QADGF36:~/Workspaces/reactUploader/webuploadtool3$ DESTINATION_PATH=/home/builder/Workspaces/reactUploader/webuploadtool3 npm start

> webuploadtool3@1.0.0 start
> node server.js

Server is running on port 3000
File saved successfully: /home/builder/Workspaces/reactUploader/webuploadtool3/downtown_berlin_corperate_office_building_clean_and_bright_2_78710595-fe99-4b58-984d-54eb5e32851d.png

I’ll next create a Dockerfile. I’m already thinking ahead of publishing the Container on ghcr.io so I’ll pack in my lable

FROM node:18
# For GHCR
LABEL org.opencontainers.image.source="https://github.com/idjohnson/expressUploader"

WORKDIR /usr/src/app

COPY package*.json ./
RUN npm install

COPY . .

EXPOSE 3000

CMD ["npm", "start"]

I can now build out a Github workflow:

$ cat .github/workflows/build.yml
name: Build and Push Docker Image to GHCR

on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1

      - name: Build and push Docker image
        env:
          DOCKER_USERNAME: idjohnson
          DOCKER_REGISTRY: ghcr.io
          IMAGE_NAME: expressupload
        run: |
          docker buildx create --use
          docker buildx build --push --tag $DOCKER_REGISTRY/$DOCKER_USERNAME/$IMAGE_NAME:latest .

To be clear here, I am not actually setting that secret. This is provided by Github meaning we can build and push without needing to store our GH PAT.

Pushing it creates a new build

/content/images/2024/03/expressupload-03.png

My first attempt got an error

/content/images/2024/03/expressupload-04.png

However, I pivoted away from buildx and the buildx action to:

name: Build and Push Docker Image to GHCR

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  build:
    permissions: write-all
    runs-on: ubuntu-latest

    steps:
      - name: 'Checkout GitHub Action'
        uses: actions/checkout@main

      - name: 'Login to GitHub Container Registry'
        uses: docker/login-action@v1
        with:
          registry: ghcr.io
          username: $
          password: $

      - name: 'Build and Push Image'
        run: |
          docker build . --tag ghcr.io/$/expressupload:latest
          docker push ghcr.io/$/expressupload:latest

This worked

/content/images/2024/03/expressupload-05.png

Now I see a package under packages:

/content/images/2024/03/expressupload-06.png

Here we can see the pull command

/content/images/2024/03/expressupload-07.png

GHCR is not anonymous, so we still need to login to use it (free, but you’ll have to let Github know who you are):

$ export CR_PAT=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

$ echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin
Login Succeeded

$ docker run -p 3333:3000 ghcr.io/idjohnson/expressupload:latest
Unable to find image 'ghcr.io/idjohnson/expressupload:latest' locally
latest: Pulling from idjohnson/expressupload
7bb465c29149: Pull complete
2b9b41aaa3c5: Pull complete
49b40be4436e: Pull complete
c558fac597f8: Pull complete
449619e06fe3: Pull complete
f52e55fee245: Pull complete
b51b5379e841: Pull complete
806ff1e3aade: Pull complete
0be895befe98: Pull complete
373220c47dd3: Pull complete
d554e55bb6e5: Pull complete
c479181ed4b3: Pull complete
Digest: sha256:89d948ce37a5cd2e2508889d1a157be07b201767a810b8a706aecab91f1c49f1
Status: Downloaded newer image for ghcr.io/idjohnson/expressupload:latest

> webuploadtool3@1.0.0 start
> node server.js

Server is running on port 3000

I can see it working

/content/images/2024/03/expressupload-08.png

Then testing an upload

/content/images/2024/03/expressupload-09.png

I see in the output

> webuploadtool3@1.0.0 start
> node server.js

Server is running on port 3000
File saved successfully: /tmp/downtown_berlin_corperate_office_building_clean_and_bright_2_78710595-fe99-4b58-984d-54eb5e32851d.png

And lastly, confirmation the file lives in the Docker container

/content/images/2024/03/expressupload-10.png

Kubernetes

Following this writeup we need to create a Kubernetes image pull secret to use GHCR.io. There is a nice guide here on how to make an Image Pull Secret.

For instance, if our Github username and PAT looks like ‘username:123123adsfasdf123123’, we can base64 that value:

$ echo username:123123adsfasdf123123 | tr -d '\n' | base64
dXNlcm5hbWU6MTIzMTIzYWRzZmFzZGYxMjMxMjM

And to turn that into a base64 auth value for our secrets, we can use:

$ echo '{"auths":{"ghcr.io":{"auth":"dXNlcm5hbWU6MTIzMTIzYWRzZmFzZGYxMjMxMjM="}}}' | tr -d '\n'  | base64 -w 0
eyJhdXRocyI6eyJnaGNyLmlvIjp7ImF1dGgiOiJkWE5sY201aGJXVTZNVEl6TVRJellXUnpabUZ6WkdZeE1qTXhNak09In19fQ==

I’ll feed that to a secret

$ kubectl get secrets dockerconfigjson-github-com -o yaml
apiVersion: v1
kind: Secret
data:
  .dockerconfigjson: eyJhdXRocyI6eyJnaGNyLmlvIjp7ImF1dGgiOiJkWE5sY201aGJXVTZNVEl6TVRJellXUnpabUZ6WkdZeE1qTXhNak09In19fQ==
metadata:
  name: dockerconfigjson-github-com

I can then use with a pod to test

apiVersion: v1
kind: Pod
metadata:
  name: name
spec:
  containers:
  - name: name
    image: ghcr.io/username/imagename:label
    imagePullPolicy: Always
  imagePullSecrets:
  - name: dockerconfigjson-github-com

I need to test while away, so I’ll build and push to dockerhub locally

builder@LuiGi17:~/Workspaces/expressUploader$ docker build -t idjohnson/expressuploader:latest .
[+] Building 108.5s (11/11) FINISHED                                                                                                                                      docker:default
 => [internal] load build definition from Dockerfile                                                                                                                                0.2s
 => => transferring dockerfile: 254B                                                                                                                                                0.0s
 => [internal] load .dockerignore                                                                                                                                                   0.2s
 => => transferring context: 2B                                                                                                                                                     0.0s
 => [internal] load metadata for docker.io/library/node:18                                                                                                                          2.4s
 => [auth] library/node:pull token for registry-1.docker.io                                                                                                                         0.0s
 => [internal] load build context                                                                                                                                                   0.2s
 => => transferring context: 95.69kB                                                                                                                                                0.1s
 => [1/5] FROM docker.io/library/node:18@sha256:aa329c613f0067755c0787d2a3a9802c7d95eecdb927d62b910ec1d28689882f                                                                  101.4s
 => => resolve docker.io/library/node:18@sha256:aa329c613f0067755c0787d2a3a9802c7d95eecdb927d62b910ec1d28689882f                                                                    0.1s
 => => sha256:aa329c613f0067755c0787d2a3a9802c7d95eecdb927d62b910ec1d28689882f 1.21kB / 1.21kB                                                                                      0.0s
 => => sha256:256406951729eec0e9f15266bf77ec0b07260c2087cba2f5ca98951c099c0154 2.00kB / 2.00kB                                                                                      0.0s
 => => sha256:39e94893115b118c1ada01fbdd42e954bb70f847933e91c3bcda278c96ad4ca2 7.34kB / 7.34kB                                                                                      0.0s
 => => sha256:2b9b41aaa3c52ab268b47da303015b94ced04a1eb02e58860e58b283404974f4 24.05MB / 24.05MB                                                                                    4.4s
 => => sha256:7bb465c2914923b08ae03b7fc67b92a1ef9b09c4c1eb9d6711b22ee6bbb46a00 49.55MB / 49.55MB                                                                                   21.7s
 => => sha256:49b40be4436eff6fe463f6977159dc727df37cabe65ade75c75c1caa3cb0a234 64.14MB / 64.14MB                                                                                   28.7s
 => => sha256:c558fac597f8ecbb7a66712e14912ce1d83b23a92ca8b6ff14eef209ab01aff2 211.12MB / 211.12MB                                                                                 67.5s
 => => sha256:449619e06fe37ae34d5d3e31b928b174f8b0eee07dfca3669c4a8e6326b8e82c 3.37kB / 3.37kB                                                                                     21.9s
 => => sha256:f52e55fee2455f6af71ffa5cfbdbb01603063f889fff52c2fd74df2673f07500 46.03MB / 46.03MB                                                                                   38.6s
 => => extracting sha256:7bb465c2914923b08ae03b7fc67b92a1ef9b09c4c1eb9d6711b22ee6bbb46a00                                                                                           9.9s
 => => sha256:b51b5379e841a41fe00a85aaf18cc0ab927c2d42ef40a9cc3211df2e2cddaf25 2.21MB / 2.21MB                                                                                     30.2s
 => => sha256:806ff1e3aadedd43a235140404bf6ff4b790d4dfd56c93a7dfb3cc78e50ebca5 450B / 450B                                                                                         30.4s
 => => extracting sha256:2b9b41aaa3c52ab268b47da303015b94ced04a1eb02e58860e58b283404974f4                                                                                           2.3s
 => => extracting sha256:49b40be4436eff6fe463f6977159dc727df37cabe65ade75c75c1caa3cb0a234                                                                                           9.6s
 => => extracting sha256:c558fac597f8ecbb7a66712e14912ce1d83b23a92ca8b6ff14eef209ab01aff2                                                                                          25.5s
 => => extracting sha256:449619e06fe37ae34d5d3e31b928b174f8b0eee07dfca3669c4a8e6326b8e82c                                                                                           0.0s
 => => extracting sha256:f52e55fee2455f6af71ffa5cfbdbb01603063f889fff52c2fd74df2673f07500                                                                                           6.8s
 => => extracting sha256:b51b5379e841a41fe00a85aaf18cc0ab927c2d42ef40a9cc3211df2e2cddaf25                                                                                           0.2s
 => => extracting sha256:806ff1e3aadedd43a235140404bf6ff4b790d4dfd56c93a7dfb3cc78e50ebca5                                                                                           0.0s
 => [2/5] WORKDIR /usr/src/app                                                                                                                                                      0.3s
 => [3/5] COPY package*.json ./                                                                                                                                                     0.1s
 => [4/5] RUN npm install                                                                                                                                                           3.2s
 => [5/5] COPY . .                                                                                                                                                                  0.2s
 => exporting to image                                                                                                                                                              0.3s
 => => exporting layers                                                                                                                                                             0.2s
 => => writing image sha256:8a0601b12a964ff8e7e77a50d719b75313c451d6a1300577829c794a1288ac7a                                                                                        0.0s
 => => naming to docker.io/idjohnson/expressuploader:latest                                                                                                                         0.1s

What's Next?
  View a summary of image vulnerabilities and recommendations → docker scout quickview
builder@LuiGi17:~/Workspaces/expressUploader$ docker push idjohnson/expressuploader:latest
The push refers to repository [docker.io/idjohnson/expressuploader]
0a51eea14f9e: Pushed
e437af4a5038: Pushed
6832492f9d34: Pushed
0b14f953e13b: Pushed
ba70e4e5685e: Mounted from library/node
5fae8bf6e262: Mounted from library/node
2793982da9d3: Mounted from library/node
2c2decbeb47f: Mounted from library/node
9fe4e8a1862c: Mounted from library/node
909275a3eaaa: Mounted from library/node
f3f47b3309ca: Mounted from library/node
1a5fc1184c48: Mounted from library/node
latest: digest: sha256:c5a6e0e260c33ae7322fd1e4942e6b10d0d2a10baad6d4c79cee6e8a17a6d32a size: 2840

Here we can create a YAML

builder@LuiGi17:~/Workspaces/expressUploader/yaml$ cat manifest.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: expressuploader-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: expressuploader
spec:
  replicas: 1
  selector:
    matchLabels:
      app: expressuploader
  template:
    metadata:
      labels:
        app: expressuploader
    spec:
      containers:
      - name: expressuploader
        image: idjohnson/expressuploader:latest
        ports:
        - containerPort: 3000
        env:
        - name: DESTINATION_PATH
          value: "/mnt/storage"
        volumeMounts:
        - name: expressuploader-storage
          mountPath: /mnt/storage
      volumes:
      - name: expressuploader-storage
        persistentVolumeClaim:
          claimName: expressuploader-pvc
---
apiVersion: v1
kind: Service
metadata:
  name: expressuploader-service
spec:
  type: ClusterIP
  ports:
    - port: 80
      targetPort: 3000
  selector:
    app: expressuploader

which we can try

$ kubectl apply -f ./manifest.yaml
persistentvolumeclaim/expressuploader-pvc created
deployment.apps/expressuploader created
service/expressuploader-service created

I can quick check and see they all exist


builder@LuiGi17:~/Workspaces/expressUploader/yaml$ kubectl get pods | grep express
expressuploader-8c86455b6-xgcgs                      1/1     Running             0             3m23s
builder@LuiGi17:~/Workspaces/expressUploader/yaml$ kubectl get pvc | grep express
expressuploader-pvc   Bound    pvc-a86b2353-c73c-4f2e-b795-9de7b5fedd23   5Gi        RWO            local-path     4m2s
builder@LuiGi17:~/Workspaces/expressUploader/yaml$ kubectl get svc | grep express
expressuploader-service                 ClusterIP      10.43.117.80    <none>                                   80/TCP                                       4m10s

/content/images/2024/03/upload2-30.png

Helm

Manifests are good, but so is good old fashioned Helm.

Let’s turn that YAML into a chart.

I’ll first delete my former

$ kubectl delete -f ./yaml/manifest.yaml
persistentvolumeclaim "expressuploader-pvc" deleted
deployment.apps "expressuploader" deleted
service "expressuploader-service" deleted

I’ll build out the files as such

builder@LuiGi17:~/Workspaces/expressUploader$ tree ./helm/
./helm/
├── Chart.yaml
├── templates
│   ├── deployment.yaml
│   ├── pvc.yaml
│   └── service.yaml
└── values.yaml

1 directory, 5 files

The Chart and values

builder@LuiGi17:~/Workspaces/expressUploader$ cat helm/Chart.yaml
apiVersion: v2
name: expressupload
description: A Helm chart for Kubernetes
type: application
version: 0.1.0
builder@LuiGi17:~/Workspaces/expressUploader$ cat helm/values.yaml
replicaCount: 1
image:
  repository: idjohnson/expressuploader
  tag: latest
  pullPolicy: IfNotPresent
service:
  type: ClusterIP
  port: 80
pvc:
  accessModes:
    - ReadWriteOnce
  storage: 5Gi
  storageClassName: "local-path"

In the deployment YAML we can see how we set the env var to the upload path serviced by the PVC

$ cat helm/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: expressuploader
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: expressuploader
  template:
    metadata:
      labels:
        app: expressuploader
    spec:
      containers:
      - name: expressuploader
        image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
        imagePullPolicy: {{ .Values.image.pullPolicy }}
        ports:
        - containerPort: 3000
        env:
        - name: DESTINATION_PATH
          value: "/mnt/storage"
        volumeMounts:
        - name: expressuploader-storage
          mountPath: /mnt/storage
      volumes:
      - name: expressuploader-storage
        persistentVolumeClaim:
          claimName: expressuploader-pvc

The service will let us specify a port, but we’ve left the target port hardcoded to 3000

$ cat helm/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: expressuploader-service
spec:
  type: {{ .Values.service.type }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: 3000
  selector:
    app: expressuploader

Lastly, the PVC

r$ cat helm/templates/pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: expressuploader-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: {{ .Values.pvc.storage }}
  storageClassName: {{ .Values.pvc.storageClassName }}

I can test an install with helm locally

$ helm install myexpressupload ./helm/
NAME: myexpressupload
LAST DEPLOYED: Tue Feb 27 18:36:21 2024
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None

Which also worked great


builder@LuiGi17:~/Workspaces/expressUploader$ kubectl get pvc expressuploader-pvc
NAME                  STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
expressuploader-pvc   Bound    pvc-300252d1-a227-431c-ac2b-5b34bd57def6   5Gi        RWO            local-path     2m26s
builder@LuiGi17:~/Workspaces/expressUploader$ kubectl get pods | grep express
expressuploader-5f5768445b-k2k87                     1/1     Running             0             2m22s
builder@LuiGi17:~/Workspaces/expressUploader$ kubectl get svc | grep express
expressuploader-service                 ClusterIP      10.43.65.220    <none>                                   80/TCP                                       2m43s

My last step is to see if Github Workflows can use GHCR.io as an OCI helm repository.

I added

      - name: 'Helm package and push'
        run: |
          helm package ./helm/
          export HELM_EXPERIMENTAL_OCI=1
          helm push expressupload-0.1.0.tgz oci://ghcr.io/$/

Then I added all this and pushed to Github

builder@LuiGi17:~/Workspaces/expressUploader$ git add helm/
builder@LuiGi17:~/Workspaces/expressUploader$ git add yaml/
builder@LuiGi17:~/Workspaces/expressUploader$ git add .github/workflows/build.yml
builder@LuiGi17:~/Workspaces/expressUploader$ git status
On branch main
Your branch is up to date with 'origin/main'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   .github/workflows/build.yml
        new file:   helm/Chart.yaml
        new file:   helm/templates/deployment.yaml
        new file:   helm/templates/pvc.yaml
        new file:   helm/templates/service.yaml
        new file:   helm/values.yaml
        new file:   yaml/manifest.yaml

builder@LuiGi17:~/Workspaces/expressUploader$ git commit -m "Add YAML, Helm, and a build step to package and publish helm"
[main 08bf91b] Add YAML, Helm, and a build step to package and publish helm
 7 files changed, 130 insertions(+), 1 deletion(-)
 create mode 100644 helm/Chart.yaml
 create mode 100644 helm/templates/deployment.yaml
 create mode 100644 helm/templates/pvc.yaml
 create mode 100644 helm/templates/service.yaml
 create mode 100644 helm/values.yaml
 create mode 100644 yaml/manifest.yaml
builder@LuiGi17:~/Workspaces/expressUploader$ git push
Enumerating objects: 18, done.
Counting objects: 100% (18/18), done.
Delta compression using up to 16 threads
Compressing objects: 100% (11/11), done.
Writing objects: 100% (14/14), 1.87 KiB | 478.00 KiB/s, done.
Total 14 (delta 4), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (4/4), completed with 2 local objects.
To https://github.com/idjohnson/expressUploader.git
   bfe81bb..08bf91b  main -> main

which worked

/content/images/2024/03/upload2-31.png

Which I can now use locally without having to have the repo cloned down

$ helm install myexpress2 oci://ghcr.io/idjohnson/expressupload --version 0.1.0
Pulled: ghcr.io/idjohnson/expressupload:0.1.0
Digest: sha256:8c6bc99b796134285269d12e23ae905d6dc9fb513475dc253b346f900db559e1
NAME: myexpress2
LAST DEPLOYED: Tue Feb 27 18:49:59 2024
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None

One last quick change to avoid that hard-coded version number

builder@LuiGi17:~/Workspaces/expressUploader$ git diff .github/workflows/build.yml
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2e37cd5..7d6d773 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -31,4 +31,4 @@ jobs:
         run: |
           helm package ./helm/
           export HELM_EXPERIMENTAL_OCI=1
-          helm push expressupload-0.1.0.tgz oci://ghcr.io/$/
\ No newline at end of file
+          helm push expressupload-*.tgz oci://ghcr.io/$/
\ No newline at end of file
builder@LuiGi17:~/Workspaces/expressUploader$ git add .github/workflows/build.yml
builder@LuiGi17:~/Workspaces/expressUploader$ git commit -m "remove hardcoded version for future use"
[main 70b7270] remove hardcoded version for future use
 1 file changed, 1 insertion(+), 1 deletion(-)
builder@LuiGi17:~/Workspaces/expressUploader$ git push
Enumerating objects: 9, done.
Counting objects: 100% (9/9), done.
Delta compression using up to 16 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (5/5), 413 bytes | 413.00 KiB/s, done.
Total 5 (delta 2), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To https://github.com/idjohnson/expressUploader.git
   08bf91b..70b7270  main -> main

The very last thing I’ll do before wrapping is to create a README.md


I had the need for a simple containerized uploader. Whether you are looking to let students upload assignments, or contractors their timecards or maybe you are running a contest or a tech conference seeking RFPs.

The files are stored in a configurable path that can be mounted to a docker volume or host path, or used in Kubernetes in a PVC.

You can set DESTINATION_PATH at app invokation

$ DESTINATION_PATH=/tmp/mypath npm start

Or with docker run

$ docker run -p 8888:3000 -e DESTINATION_PATH=/tmp/mypath ghcr.io/idjohnson/expressupload:latest

It is not exposed with helm because the destination maps to a PVC which is configuration on sizes

$ helm install myexpress2 --set pvc.storage=10Gi oci://ghcr.io/idjohnson/expressupload --version 0.1.0

Summary

I hope you enjoyed this quick writeup on how to create and use an ExpressJS based containerized web uploader. There is certainly lots we can do to extend this and refine it. However, the goal here was to just walk through a simple start to finish app

Kubernetes Docker OpenSource

Have something to add? Feedback? You can use the feedback form

Isaac Johnson

Isaac Johnson

Cloud Solutions Architect

Isaac is a CSA and DevOps engineer who focuses on cloud migrations and devops processes. He also is a dad to three wonderful daughters (hence the references to Princess King sprinkled throughout the blog).

Theme built by C.S. Rhymes