Clone
defmodule Mix.Tasks.Deploy do
use Mix.Task
@shortdoc "Deploy Phoenix application to VPS"
@moduledoc """
Deploy Phoenix application to a VPS using SSH.
## Usage
mix deploy # Deploy to production (default)
mix deploy staging # Deploy to staging
mix deploy staging feature-xyz # Deploy specific branch to staging
## Configuration
Configure deployment in `config/deploy.exs`:
import Config
config :deploy,
repository: "git@github.com:username/app-name.git",
shared_dirs: ["uploads", "tmp", "logs"],
shared_files: [".env"],
build_script: "./build.sh"
config :deploy, :production,
branch: "main",
user: "dev",
domain: "ssh.example.com",
port: 22,
deploy_to: "/var/www/app-name",
url: "https://app.example.com",
app_port: 4000
config :deploy, :staging,
branch: "develop",
user: "dev",
domain: "ssh.example.com",
port: 22,
deploy_to: "/var/www/staging.app-name",
url: "https://staging.app.example.com",
app_port: 4001
"""
require Logger
@impl Mix.Task
def run(args) do
# Load deployment config
Mix.Task.run("loadconfig", ["config/deploy.exs"])
{env, branch} = parse_args(args)
config = get_deploy_config(env)
# Allow branch override
config = if branch, do: Map.put(config, :branch, branch), else: config
Logger.info("Deploying to #{env} environment...")
Logger.info("Branch: #{config.branch}")
Logger.info("Host: #{config.user}@#{config.domain}:#{config.port}")
Logger.info("Deploy to: #{config.deploy_to}")
# Execute deployment
with :ok <- check_requirements(config),
{:ok, config} <- create_release_dir(config),
:ok <- clone_or_fetch_code(config),
:ok <- link_shared_paths(config),
:ok <- run_build_script(config),
:ok <- update_symlinks(config),
:ok <- restart_service(config),
:ok <- cleanup_old_releases(config),
:ok <- health_check(config) do
Logger.info("✅ Deployment completed successfully!")
else
{:error, reason} ->
Logger.error("❌ Deployment failed: #{reason}")
exit(1)
end
end
defp parse_args([]), do: {:production, nil}
defp parse_args([env]), do: {String.to_atom(env), nil}
defp parse_args([env, branch]), do: {String.to_atom(env), branch}
defp get_deploy_config(env), do: Deploy.SSH.get_deploy_config(env)
defp check_requirements(config) do
required_keys = [:repository, :user, :domain, :deploy_to, :branch]
missing = Enum.filter(required_keys, &(not Map.has_key?(config, &1)))
if Enum.empty?(missing) do
:ok
else
{:error, "Missing required config keys: #{inspect(missing)}"}
end
end
defp create_release_dir(config) do
timestamp = DateTime.utc_now() |> DateTime.to_unix() |> to_string()
release_path = "#{config.deploy_to}/releases/#{timestamp}"
case ssh_exec(config, "mkdir -p #{release_path}") do
{_, 0} ->
config = Map.put(config, :release_path, release_path)
{:ok, config}
{output, _} ->
{:error, "Failed to create release directory: #{output}"}
end
end
defp clone_or_fetch_code(%{release_path: release_path} = config) do
repo_path = "#{config.deploy_to}/repo"
# Clone or update repository
clone_cmd = """
if [ -d #{repo_path} ]; then
cd #{repo_path} && git fetch origin
else
git clone #{config.repository} #{repo_path}
fi
"""
case ssh_exec(config, clone_cmd) do
{_, 0} ->
# Archive the specific branch to release directory (use origin/ to get fetched code)
archive_cmd = """
cd #{repo_path} && \
git archive origin/#{config.branch} | tar -x -C #{release_path}
"""
case ssh_exec(config, archive_cmd) do
{_, 0} -> :ok
{output, _} -> {:error, "Failed to archive code: #{output}"}
end
{output, _} ->
{:error, "Failed to clone/fetch repository: #{output}"}
end
end
defp link_shared_paths(%{release_path: release_path} = config) do
shared_path = "#{config.deploy_to}/shared"
# Create shared directories if they don't exist
shared_dirs = Map.get(config, :shared_dirs, [])
shared_files = Map.get(config, :shared_files, [])
# Create shared structure
create_shared_cmd = """
mkdir -p #{shared_path} && \
#{Enum.map(shared_dirs, fn dir -> "mkdir -p #{shared_path}/#{dir}" end) |> Enum.join(" && ")}
"""
case ssh_exec(config, create_shared_cmd) do
{_, 0} ->
# Link shared directories
link_dirs_cmd = shared_dirs
|> Enum.map(fn dir ->
"ln -nfs #{shared_path}/#{dir} #{release_path}/#{dir}"
end)
|> Enum.join(" && ")
# Link shared files
link_files_cmd = shared_files
|> Enum.map(fn file ->
dir = Path.dirname(file)
"mkdir -p #{release_path}/#{dir} && " <>
"if [ -f #{shared_path}/#{file} ]; then " <>
"ln -nfs #{shared_path}/#{file} #{release_path}/#{file}; " <>
"fi"
end)
|> Enum.join(" && ")
commands = [link_dirs_cmd, link_files_cmd]
|> Enum.reject(&(&1 == ""))
if Enum.empty?(commands) do
:ok
else
full_cmd = Enum.join(commands, " && ")
case ssh_exec(config, full_cmd) do
{_, 0} -> :ok
{output, _} -> {:error, "Failed to link shared paths: #{output}"}
end
end
{output, _} ->
{:error, "Failed to create shared directories: #{output}"}
end
end
defp run_build_script(%{release_path: release_path} = config) do
build_script = Map.get(config, :build_script, "./build.sh")
Logger.info("Running build script...")
# First make it executable
chmod_cmd = "chmod +x #{release_path}/#{build_script}"
case ssh_exec(config, chmod_cmd) do
{_, 0} ->
# Run the build script with explicit bash to ensure profile is loaded
build_cmd = "cd #{release_path} && /bin/bash -l -c '#{build_script}'"
case ssh_exec(config, build_cmd) do
{_, 0} -> :ok
{output, _} -> {:error, "Build failed: #{output}"}
end
{output, _} ->
{:error, "Failed to make build script executable: #{output}"}
end
end
defp update_symlinks(%{release_path: release_path} = config) do
current_path = "#{config.deploy_to}/current"
# Update current symlink atomically
update_cmd = """
ln -nfs #{release_path} #{current_path}.tmp && \
mv -Tf #{current_path}.tmp #{current_path}
"""
case ssh_exec(config, update_cmd) do
{_, 0} -> :ok
{output, _} -> {:error, "Failed to update symlinks: #{output}"}
end
end
defp restart_service(config) do
# Service name is {app_name}-{env} (e.g., testapp-staging, testapp-production)
service_name = "#{config.app_name}-#{config.env}"
restart_cmd = """
sudo systemctl daemon-reload && \
sudo systemctl restart #{service_name} && \
sudo systemctl status #{service_name} | head -n 10
"""
Logger.info("Restarting service: #{service_name}")
case ssh_exec(config, restart_cmd) do
{output, 0} ->
Logger.info(output)
:ok
{output, _} ->
{:error, "Failed to restart service: #{output}"}
end
end
defp cleanup_old_releases(config) do
# Keep only the last 5 releases
cleanup_cmd = """
cd #{config.deploy_to}/releases && \
ls -t | tail -n +6 | xargs -r rm -rf
"""
case ssh_exec(config, cleanup_cmd) do
{_, 0} -> :ok
{_, _} ->
Logger.warning("Failed to cleanup old releases")
:ok # Don't fail deployment for cleanup
end
end
defp health_check(config) do
if url = Map.get(config, :url) do
Logger.info("Performing health check...")
health_cmd = "curl -fsSL -o /dev/null -w '%{http_code}' --retry 3 --retry-delay 2 #{url}"
case ssh_exec(config, health_cmd) do
{"200", 0} ->
Logger.info("✅ Health check passed!")
:ok
{status, 0} ->
Logger.warning("Health check returned status: #{status}")
:ok # Don't fail for non-200 status
{output, _} ->
Logger.warning("Health check failed: #{output}")
:ok # Don't fail deployment for health check
end
else
:ok
end
end
defp ssh_exec(config, command, _opts \\ []), do: Deploy.SSH.ssh_exec(config, command)
defp scp_upload(config, local_file, remote_file), do: Deploy.SSH.scp_upload(config, local_file, remote_file)
end