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
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.
| Path | When 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:
npm run build, is it static files talking to APIs?
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.
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.
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 "/*"
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
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
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.
# 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
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.
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.
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. |