Deploy a Vue.js App to AWS with Docker

Ship a Vue build two ways: static to S3 and CloudFront, or containerized with Docker and run on EC2 through ECR, with SPA routing handled correctly on both.

Published Last reviewed

1

Two Ways to Ship the Same Build

Whichever route you pick, the app is the same pile of files: the dist/ folder that npm run build produces. What differs is what serves it. The choice comes down to whether you want a server in the picture at all.

PathWhen it fits
S3 + CloudFront A pure single-page app that talks to APIs over the network. No server to patch, pennies per month, and a global CDN in front. This is the default for a front-end-only Vue app, and what most projects should reach for first.
Docker on EC2 You want a real box: server-side rendering, a bundled backend, non-trivial nginx rules, or simply the same container you run everywhere else. More to manage, but full control and no static-hosting constraints.

Docker still earns its place in both. On the EC2 path it is the unit of deployment. On the S3 path it gives you a reproducible build environment, so the bundle that ships is the one your pipeline produced, not whatever Node version happened to be on the machine that ran the build.


Not sure which path? Answer three questions:

1. After npm run build, is it static files talking to APIs?
2. What does traffic look like?
3. Does your team already run containers in production?
2

The Production Build

Everything downstream depends on one command. From the project root:

# install exactly what package-lock.json pins, then build
npm ci
npm run build

# output lands in dist/ : index.html plus hashed JS/CSS in assets/
ls dist/

Two build-time details bite people later, so settle them now. First, environment variables are baked into the bundle at build time, not read at runtime. In Vite, only names prefixed VITE_ are exposed, and their values are frozen into the JavaScript the moment you build:

# .env.production, read automatically by `vite build`
VITE_API_BASE=https://api.example.com

The practical upshot: one build is tied to one API target. If staging and production hit different back ends, you build twice, not once. Second, if the app will not sit at the domain root, set base in vite.config.js so asset URLs resolve. Serving from a subpath with the default base: '/' is the classic "blank page, 404s in the console" first deploy.

3

Path A: S3 and CloudFront

S3 holds the files; CloudFront caches them at edge locations and serves them over HTTPS. Create a bucket and push the build into it:

# bucket names are globally unique; this one is just private storage,
# CloudFront is what the public actually reaches
aws s3 mb s3://my-vue-app

# --delete removes files on S3 that are gone from dist/, so old
# hashed bundles do not pile up forever
aws s3 sync dist/ s3://my-vue-app --delete

Put a CloudFront distribution in front of the bucket with the bucket as its origin, locked down with Origin Access Control so the bucket stays private and only CloudFront can read it. That part is standard. The part specific to a single-page app is the routing fix.

The SPA routing problem

Vue Router owns paths like /dashboard in the browser, but that path does not exist as a file in S3. A visitor who loads /dashboard directly, or refreshes there, asks S3 for an object that is not there and gets a 403 or 404. The fix is to answer both with index.html so the app boots and the router takes the URL from there. In CloudFront, add two custom error responses:

# CloudFront -> Error pages -> Create custom error response
HTTP error code:        403
Customize error response: Yes
Response page path:      /index.html
HTTP response code:     200

# then the same four lines again for 404

Last piece: CloudFront caches aggressively, so a fresh s3 sync is invisible until the cache expires. Invalidate it as the final deploy step. Because the JS and CSS filenames are content-hashed, only index.html truly needs busting, but /* is simplest and the first 1,000 invalidation paths each month are free:

aws cloudfront create-invalidation --distribution-id E1A2B3C4D5 --paths "/*"
4

Dockerizing the App

For the EC2 path, the app ships as a container. The right shape is a multi-stage build: a heavy Node image compiles the app, then a tiny nginx image serves the result. The Node toolchain never makes it into the final image, so it stays small and has almost no attack surface.

# ---- stage 1: build the bundle ----
FROM node:20-alpine AS build
WORKDIR /app

# copy manifests first so `npm ci` is cached until deps change
COPY package*.json ./
RUN npm ci

# then the source, and build
COPY . .
RUN npm run build

# ---- stage 2: serve the static files ----
FROM nginx:alpine

# only the compiled output crosses over from the build stage
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Pair it with a .dockerignore so the build context stays lean and the host's node_modules never leaks into the image:

# .dockerignore
node_modules
dist
.git
.env.local
nginx.conf: the same SPA fallback, on the server

The routing problem from the S3 path exists here too, and the fix is the nginx equivalent: try the requested file, then a directory, then fall back to index.html. That one try_files line is the whole trick.

server {
    listen       80;
    server_name  _;
    root         /usr/share/nginx/html;
    index        index.html;

    # serve the file if it exists, otherwise hand the SPA its entry point
    location / {
        try_files $uri $uri/ /index.html;
    }

    # hashed assets are immutable, so let the browser cache them hard
    location /assets/ {
        expires     1y;
        add_header  Cache-Control "public, immutable";
    }
}

Build it and run it locally to confirm the container works before AWS enters the picture. If http://localhost:8080 serves the app and a hard refresh on a client-side route still loads, the image is deploy-ready:

docker build -t vue-app .
docker run -p 8080:80 vue-app
5

Path B: Run the Container on EC2

EC2 pulls the image from a registry rather than building it on the box, and on AWS that registry is ECR. The flow is: create a repository, push the image to it, then pull and run on the instance.

Push the image to ECR
# one-time: create the repository
aws ecr create-repository --repository-name vue-app

# authenticate Docker to your private registry (token lasts 12 hours)
aws ecr get-login-password --region us-east-1 \
  | docker login --username AWS --password-stdin 111122223333.dkr.ecr.us-east-1.amazonaws.com

# tag the local image with the full registry path, then push
docker tag vue-app:latest 111122223333.dkr.ecr.us-east-1.amazonaws.com/vue-app:latest
docker push 111122223333.dkr.ecr.us-east-1.amazonaws.com/vue-app:latest
Pull and run on the instance

On the EC2 host, with Docker installed and an IAM role attached that allows ECR pulls, authenticate the same way, then run the container. The --restart policy brings it back after a crash or a reboot:

aws ecr get-login-password --region us-east-1 \
  | docker login --username AWS --password-stdin 111122223333.dkr.ecr.us-east-1.amazonaws.com

docker pull 111122223333.dkr.ecr.us-east-1.amazonaws.com/vue-app:latest

# map the host's port 80 to the container's; -d runs it detached
docker run -d --restart unless-stopped -p 80:80 \
  111122223333.dkr.ecr.us-east-1.amazonaws.com/vue-app:latest

The one thing that makes this look broken when it is not: the instance's security group. Inbound port 80 (and 443 once you add TLS) has to be open, or the container runs happily while every request from outside times out. For real traffic, put an Application Load Balancer in front to terminate HTTPS and keep the instance itself off the public internet.

6

Automating the Deploy

Neither path should stay a manual checklist. Wire it into CI so a push to main ships the app. The S3 path is the shorter one: build, sync, invalidate. Using GitHub Actions with an OIDC role (no long-lived keys in the repo):

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write      # required for OIDC
      contents: read
    steps:
      - uses: actions/checkout@v6

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE }}
          aws-region: us-east-1

      - run: npm ci && npm run build
      - run: aws s3 sync dist/ s3://my-vue-app --delete
      - run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CF_DIST_ID }} --paths "/*"

The Docker path swaps the last two steps for a build, an ECR push, and a nudge to the instance to pull and restart the container. The moving parts of the workflow itself (triggers, secrets, OIDC, caching) are covered in the GitHub Actions guide; this is the deploy step that slots onto the end of it.

7

Reference

Command Purpose
npm run build Compiles the app into dist/. The input to every deploy path.
aws s3 sync dist/ s3://bucket --delete Uploads the build and prunes files no longer present. The S3 deploy.
aws cloudfront create-invalidation Clears the CDN cache so the new build is served immediately.
try_files $uri $uri/ /index.html The nginx line that keeps client-side routes working on refresh.
docker build -t vue-app . Builds the multi-stage image (Node build stage, nginx serve stage).
aws ecr get-login-password | docker login Authenticates Docker to your private ECR registry.
docker run -d --restart unless-stopped -p 80:80 Runs the container on EC2, surviving crashes and reboots.