No static assets found on production using Docker Containers

Hi,

elixir -v
Erlang/OTP 23 [erts-11.0.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [hipe] [dtrace]

Elixir 1.10.3 (compiled with Erlang/OTP 22)

And my deps.

mix deps.get             
Resolving Hex dependencies...
Dependency resolution completed:
Unchanged:
  bamboo 1.5.0
  bamboo_smtp 2.1.0
  certifi 2.5.2
  connection 1.0.4
  cowboy 2.8.0
  cowlib 2.9.1
  csv 2.3.1
  db_connection 2.2.2
  decimal 1.8.1
  ecto 3.4.6
  ecto_sql 3.4.5
  file_system 0.2.8
  gen_smtp 0.15.0
  gettext 0.18.1
  hackney 1.16.0
  idna 6.0.1
  jason 1.2.1
  metrics 1.0.1
  mime 1.4.0
  mimerl 1.2.0
  parallel_stream 1.0.6
  parse_trans 3.3.0
  phoenix 1.5.4
  phoenix_ecto 4.1.0
  phoenix_html 2.14.2
  phoenix_live_dashboard 0.2.7
  phoenix_live_reload 1.2.4
  phoenix_live_view 0.14.4
  phoenix_pubsub 2.0.0
  plug 1.10.4
  plug_cowboy 2.3.0
  plug_crypto 1.1.2
  postgrex 0.15.5
  pow 1.0.20
  ranch 1.7.1
  ssl_verify_fun 1.1.6
  telemetry 0.4.2
  telemetry_metrics 0.5.0
  telemetry_poller 0.5.1
  unicode_util_compat 0.5.0

mix.exs:

defmodule MyApp.MixProject do
  use Mix.Project

  def project do
    [
      app: :myapp,
      version: "0.1.0",
      elixir: "~> 1.7",
      elixirc_paths: elixirc_paths(Mix.env()),
      compilers: [:phoenix, :gettext] ++ Mix.compilers(),
      start_permanent: Mix.env() == :prod,
      aliases: aliases(),
      deps: deps()
    ]
  end

  # Configuration for the OTP application.
  #
  # Type `mix help compile.app` for more information.
  def application do
    [
      mod: {MyApp.Application, []},
      extra_applications: [:logger, :runtime_tools]
    ]
  end

  # Specifies which paths to compile per environment.
  defp elixirc_paths(:test), do: ["lib", "test/support"]
  defp elixirc_paths(_), do: ["lib"]

  # Specifies your project dependencies.
  #
  # Type `mix help deps` for examples and options.
  defp deps do
    [
      {:phoenix, "~> 1.5.3"},
      {:phoenix_pubsub, "~> 2.0"},
      {:phoenix_ecto, "~> 4.1"},
      {:ecto_sql, "~> 3.4"},
      {:postgrex, ">= 0.0.0"},
      {:phoenix_html, "~> 2.11"},
      {:phoenix_live_reload, "~> 1.2", only: :dev},
      {:phoenix_live_dashboard, "~> 0.2.0"},
      {:telemetry_metrics, "~> 0.4"},
      {:telemetry_poller, "~> 0.4"},
      {:gettext, "~> 0.11"},
      {:jason, "~> 1.0"},
      {:plug_cowboy, "~> 2.0"},
      {:pow, "~> 1.0.20"},
      {:bamboo, "~> 1.5"},
      {:bamboo_smtp, "~> 2.1.0"},
      {:csv, "~> 2.3.1"}
    ]
  end

  # Aliases are shortcuts or tasks specific to the current project.
  # For example, to create, migrate and run the seeds file at once:
  #
  #     $ mix ecto.setup
  #
  # See the documentation for `Mix` for more info on aliases.
  defp aliases do
    [
      "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
      "ecto.reset": ["ecto.drop", "ecto.setup"],
      test: ["ecto.create --quiet", "ecto.migrate", "test"]
    ]
  end
end

config/releases.exs

import Config

database_url =
  System.get_env("DATABASE_URL") ||
    raise """
    environment variable DATABASE_URL is missing.
    For example: ecto://USER:PASS@HOST/DATABASE
    """

secret_key_base =
  System.get_env("SECRET_KEY_BASE") ||
    raise """
    environment variable SECRET_KEY_BASE is missing.
    You can generate one by calling: mix phx.gen.secret
    """

config :logger, :console,
  level: :info,
  format: "$time $metadata[$level] $message\n",
  metadata: [:request_id]

config :myapp, MyApp.Repo,
  # ssl: true,
  url: database_url,
  pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")

config :myapp, MyAppWeb.Endpoint,
  http: [
    port: String.to_integer(System.get_env("PORT") || "4000"),
    transport_options: [socket_opts: [:inet6]]
  ],
  url: [host: System.get_env("HOST"), port: 80],
  server: true,
  cache_static_manifest: "priv/static/cache_manifest.json",
  secret_key_base: secret_key_base,
  check_origin: [System.get_env("HOST")]

This is not my first production release for Phoenix but I upgraded the the project to the versions above.
I followed latest Deployment Documentation: https://hexdocs.pm/phoenix/releases.html#containers

Here’s my webpack.config file

const path = require('path');
const glob = require('glob');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = (env, options) => ({
  optimization: {
    minimizer: [
      new UglifyJsPlugin({
        cache: true,
        parallel: true,
        sourceMap: false
      }),
      new OptimizeCSSAssetsPlugin({})
    ]
  },
  entry: {
    './js/app.js': ['./js/app.js'].concat(glob.sync('./vendor/**/*.js'))
  },
  output: {
    filename: 'app.js',
    path: path.resolve(__dirname, '../priv/static/js')
  },
  module: {
    rules: [{
      test: /\.js$/,
      exclude: /node_modules/,
      use: {
        loader: 'babel-loader'
      }
    }, {
      test: /\.s?css$/,
      use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']
    }]
  },
  plugins: [
    new MiniCssExtractPlugin({ filename: '../css/app.scss' }),
    new CopyWebpackPlugin([{ from: 'static/', to: '../' }])
  ]
});

Here’s my DockerFile

FROM elixir:1.9.4-alpine as build

# install build dependencies

RUN apk add --update git build-base nodejs npm yarn python

# prepare build dir

RUN mkdir /app

WORKDIR /app

# install hex + rebar

RUN mix local.hex --force && \

mix local.rebar --force

# set build ENV

ENV MIX_ENV=prod

# install mix dependencies

COPY mix.exs mix.lock ./

COPY config config

RUN mix do deps.get, deps.compile

# build assets

COPY assets/package.json assets/package-lock.json ./assets/

RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error

COPY priv priv

COPY assets assets

RUN npm run --prefix ./assets deploy

RUN mix phx.digest

# compile and build release

COPY lib lib

# uncomment COPY if rel/ exists

# COPY rel rel

RUN mix do compile, release

# prepare release image

FROM alpine:3.9 as app

RUN apk add --update bash openssl

RUN apk add --no-cache ncurses-libs

RUN mkdir /app

WORKDIR /app

COPY entrypoint.sh ./

COPY --from=build /app/_build/prod/rel/myapp ./

RUN chown -R nobody: /app

USER nobody

EXPOSE 4004

ENV HOME=/app

CMD ["bash", "/app/entrypoint.sh"]

and entrypoint.sh file

# File: my_app/entrypoint.sh

#!/bin/bash

# docker entrypoint script.

bin="/app/bin/myapp"

# start the elixir application

exec "$bin" "start"

exec "$bin" "eval MyApp.Release.migrate"

which is create an docker container image and accessible from outside however there’s no static files found. So, the web site is pure html without theme, images etc.

I can’t find what’s wrong?

It is likely an issue with the webpack copy plugin. Your dockerfile looks fine, albeit many unnecessary layers.

You might need to set a folder context, especially if it is in a sub directory (i.e. webpack config is not in the root).

 plugins: [
    new MiniCssExtractPlugin({ filename: '../css/app.scss' }),
    new CopyWebpackPlugin([{ context: '../', from: 'static/', to: '../' }])
  ]

But it’s okay in local. You might be right actually, I saw a mistake but when I fixed, it’s still same… here’s my folder structure for assets from root.

tree assets -L 2 -I node_modules
assets
├── css
│   └── app.scss
├── js
│   ├── app.js
│   ├── materialize
│   ├── search.js
│   └── socket.js
├── package-lock.json
├── package.json
├── sass
│   ├── components
│   └── materialize.scss
├── static
│   ├── css
│   ├── favicon.ico
│   ├── fonts
│   ├── images
│   └── robots.txt
└── webpack.config.js

and I changed my webpack.config related part to:

  plugins: [
    new MiniCssExtractPlugin({ filename: './css/app.scss' }),
    new CopyWebpackPlugin([{ from: 'static/', to: './' }])
  ]

still works okay in my local but production is same…

okay new findings:

I removed manually priv/static folder so webpack re-build it.

But I had to change new CopyWebpackPlugin([{ from: 'static/', to: './' }]) to:
new CopyWebpackPlugin([{ from: 'static/', to: '../' }]) otherwise it was copying everyting into priv/static/js/

Now I can see it’s probably copying the files, however I’m having problem with sass to css. I’m close to solve it.

I encountered a similar problem today, I normally use podman-containerized npm and I needed to update npm-sass inside the container. If you can check on the version of that… Making sure it’s latest might be your key.

It’s all latest version. I guess I’ll just give up on webpack. I’ll try to remove it, I’m sick of it every version creates more problems.

Did you add in the context object key?

no… here’s how I solved, before get rid off the webpack, I gave up sass, so converted everything to css, remove the saas loader and all good.

latest webpack.config.js

const path = require('path');
const glob = require('glob');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = (env, options) => ({
  optimization: {
    minimizer: [
      new UglifyJsPlugin({
        cache: false,
        parallel: true,
        sourceMap: false
      }),
      new OptimizeCSSAssetsPlugin({})
    ]
  },
  entry: {
    './js/app.js': ['./js/app.js'].concat(glob.sync('./vendor/**/*.js'))
  },
  output: {
    filename: 'app.js',
    path: path.resolve(__dirname, '../priv/static/js')
  },
  module: {
    rules: [{
      test: /\.js$/,
      exclude: /node_modules/,
      use: {
        loader: 'babel-loader'
      }
    }, {
      test: /\.css$/i,
      use: [MiniCssExtractPlugin.loader, 'css-loader']
    }]
  },
  plugins: [
    new MiniCssExtractPlugin({ filename: './css/app.css' }),
    new CopyWebpackPlugin([{ from: 'static/', to: '../' }]),
    new CopyWebpackPlugin([{ from: 'css/', to: '../css/' }]),
  ]
});

Now all good!

P.S.: Next time I’ll avoid to use webpack at all, even though it has great benefits, but just another headache.